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

type-ruby / t-ruby / 20573122825

29 Dec 2025 12:41PM UTC coverage: 92.341% (+13.3%) from 79.076%
20573122825

push

github

web-flow
feat: improve error messages with tsc-style diagnostics (#30)

* feat: add Diagnostic class for unified error structure

- Add Diagnostic class with code, message, file, line, column attributes
- Add factory methods: from_type_check_error, from_parse_error, from_scan_error
- Add comprehensive tests for all functionality
- TR1xxx codes for parser errors, TR2xxx for type errors

* feat: add DiagnosticFormatter with tsc-style output

- Format errors as file:line:col - severity CODE: message
- Display source code snippets with line numbers
- Show error markers (~~~) under problem location
- Include Expected/Actual/Suggestion context
- Support ANSI colors with TTY auto-detection
- Format summary line: Found X errors and Y warnings

* feat: add ErrorReporter for collecting and reporting errors

- Collect multiple diagnostics during compilation
- Convert TypeCheckError, ParseError, ScanError to Diagnostic
- Auto-load source from file when not provided
- Report formatted output using DiagnosticFormatter
- Track error vs warning counts

* feat: integrate ErrorReporter into CLI

- Use ErrorReporter for TypeCheckError, ParseError, ScanError
- Display tsc-style formatted error output
- Include source code snippets and error markers
- Show Expected/Actual/Suggestion context
- Display summary line with error count

* refactor: use DiagnosticFormatter in Watcher

- Replace hash-based error format with Diagnostic objects
- Use DiagnosticFormatter for consistent tsc-style output
- Include source code snippets in watch mode errors
- Update tests to expect Diagnostic objects

* feat: add location info to MethodDef for better error messages

- TokenDeclarationParser: capture def token's line/column
- Parser.parse_function_with_body: add line/column to func_info
- Parser.parse_method_in_class: add line/column to method_info
- IR CodeGenerator.build_method: pass location to MethodDef

Error messages now show exact line:column position:
  src/file.trb:18:1 - error TR2001: T... (continued)

571 of 640 new or added lines in 14 files covered. (89.22%)

4 existing lines in 2 files now uncovered.

8210 of 8891 relevant lines covered (92.34%)

1046.45 hits per line

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

85.47
/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) }
122✔
31
      @config = config || Config.new
60✔
32
      @compiler = Compiler.new(@config)
60✔
33
      @error_count = 0
60✔
34
      @file_count = 0
60✔
35
      @use_colors = $stdout.tty?
60✔
36
      @file_diagnostics = {} # Cache diagnostics per file for incremental updates
60✔
37

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

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

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

54
      # Statistics
55
      @stats = {
60✔
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)
3✔
101
    end
102

103
    def watch_directories
1✔
104
      if @paths == [File.expand_path(".")]
3✔
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) }
8✔
107
      else
108
        # Specific paths provided: watch those paths
109
        @paths.map { |path| watch_directory(path) }.uniq
2✔
110
      end
111
    end
112

113
    def handle_changes(modified, added, removed)
1✔
114
      changed_files = (modified + added)
2✔
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?
2✔
118

119
      puts
1✔
120
      print_file_change_message
1✔
121

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

130
      if changed_files.any?
1✔
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
1✔
136
      end
137
    end
138

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

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

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

157
      all_diagnostics = @file_diagnostics.values.flatten
1✔
158
      print_errors(all_diagnostics)
1✔
159
      print_summary
1✔
160
    end
161

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

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

179
        # Run cross-file check if enabled
180
        if @cross_file_check && @incremental_compiler.cross_file_checker
2✔
181
          check_result = @incremental_compiler.cross_file_checker.check_all
2✔
182
          check_result[:errors].each do |e|
2✔
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|
1✔
190
          result = compile_file(file)
1✔
191
          # Update cached diagnostics for this file
192
          @file_diagnostics[file] = result[:diagnostics] || []
1✔
193
          compiled_count += 1
1✔
194
        end
195
      end
196

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

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

203
      @file_count = compiled_count
3✔
204
      print_errors(all_diagnostics)
3✔
205
      print_summary
3✔
206
      print_watching_message
3✔
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
212
      compile_result = @compiler.compile_with_diagnostics(file)
2✔
213

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

217
      {
218
        file: file,
2✔
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)
7✔
227

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

231
      {
232
        file: file,
7✔
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")
9✔
240
    end
241

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

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

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

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

268
      files.uniq
15✔
269
    end
270

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

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

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

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

290
      Diagnostic.new(
4✔
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✔
301
      return if diagnostics.empty?
6✔
302

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

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

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

321
    def print_summary
1✔
322
      puts
7✔
323
      if @error_count.zero?
7✔
324
        msg = "Found #{colorize(:green, "0 errors")}. Watching for file changes."
5✔
325
      else
326
        error_word = @error_count == 1 ? "error" : "errors"
2✔
327
        msg = "Found #{colorize(:red, "#{@error_count} #{error_word}")}. Watching for file changes."
2✔
328
      end
329
      puts "#{colorize(:gray, timestamp)} #{msg}"
7✔
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
2✔
338
      puts "#{colorize(:gray, timestamp)} #{colorize(:bold, "Watch Mode Statistics:")}"
2✔
339
      puts "  Total compilations: #{@stats[:total_compilations]}"
2✔
340
      puts "  Incremental cache hits: #{@stats[:incremental_hits]}"
2✔
341
      total = @stats[:total_compilations] + @stats[:incremental_hits]
2✔
342
      hit_rate = if total.positive?
2✔
343
                   (@stats[:incremental_hits].to_f / total * 100).round(1)
1✔
344
                 else
345
                   0
1✔
346
                 end
347
      puts "  Cache hit rate: #{hit_rate}%"
2✔
348
      puts "  Total compile time: #{@stats[:total_time].round(2)}s"
2✔
349
    end
350

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

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

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

363
      "#{COLORS[color]}#{text}#{COLORS[:reset]}"
1✔
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