• 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

93.6
/lib/t_ruby/error_handler.rb
1
# frozen_string_literal: true
2

3
module TRuby
1✔
4
  class ErrorHandler
1✔
5
    VALID_TYPES = %w[String Integer Float Boolean Array Hash Symbol void nil].freeze
1✔
6
    # Unicode-aware identifier pattern for method/variable names (supports Korean, etc.)
7
    IDENTIFIER_PATTERN = /[\w\p{L}\p{N}]+[!?]?/
1✔
8

9
    def initialize(source)
1✔
10
      @source = source
172✔
11
      @lines = source.split("\n")
172✔
12
      @errors = []
172✔
13
      @functions = {}
172✔
14
      @type_parser = ParserCombinator::TypeParser.new
172✔
15
    end
16

17
    def check
1✔
18
      @errors = []
172✔
19
      @functions = {}
172✔
20
      @type_aliases = {}
172✔
21
      @interfaces = {}
172✔
22

23
      check_type_alias_errors
172✔
24
      check_interface_errors
172✔
25
      check_syntax_errors
172✔
26
      check_method_signature_errors
172✔
27
      check_type_validation
172✔
28
      check_duplicate_definitions
172✔
29

30
      @errors
172✔
31
    end
32

33
    private
1✔
34

35
    def check_interface_errors
1✔
36
      @lines.each_with_index do |line, idx|
172✔
37
        next unless line.match?(/^\s*interface\s+[\w:]+/)
507✔
38

39
        match = line.match(/^\s*interface\s+([\w:]+)/)
29✔
40
        next unless match
29✔
41

42
        interface_name = match[1]
29✔
43

44
        if @interfaces[interface_name]
29✔
45
          @errors << "Line #{idx + 1}: Interface '#{interface_name}' is already defined at line #{@interfaces[interface_name]}"
2✔
46
        else
47
          @interfaces[interface_name] = idx + 1
27✔
48
        end
49
      end
50
    end
51

52
    def check_type_alias_errors
1✔
53
      @lines.each_with_index do |line, idx|
172✔
54
        next unless line.match?(/^\s*type\s+\w+/)
507✔
55

56
        match = line.match(/^\s*type\s+(\w+)\s*=\s*(.+)$/)
16✔
57
        next unless match
16✔
58

59
        alias_name = match[1]
16✔
60

61
        if @type_aliases[alias_name]
16✔
62
          @errors << "Line #{idx + 1}: Type alias '#{alias_name}' is already defined at line #{@type_aliases[alias_name]}"
3✔
63
        else
64
          @type_aliases[alias_name] = idx + 1
13✔
65
        end
66
      end
67
    end
68

69
    def check_syntax_errors
1✔
70
      @lines.each_with_index do |line, idx|
172✔
71
        next unless line.match?(/^\s*def\s+/)
507✔
72

73
        # Check for unclosed parenthesis
74
        if line.match?(/def\s+\w+\([^)]*$/) && @lines[(idx + 1)..].none? { |l| l.match?(/\)/) }
150✔
75
          @errors << "Line #{idx + 1}: Potential unclosed parenthesis in function definition"
2✔
76
        end
77

78
        # Check for invalid parameter syntax (e.g., "def test(: String)")
79
        if line.match?(/def\s+\w+\(\s*:\s*\w+/)
148✔
80
          @errors << "Line #{idx + 1}: Invalid parameter syntax - parameter name missing"
3✔
81
        end
82
      end
83
    end
84

85
    # New comprehensive method signature validation
86
    def check_method_signature_errors
1✔
87
      @lines.each_with_index do |line, idx|
172✔
88
        next unless line.match?(/^\s*def\s+/)
507✔
89

90
        check_single_method_signature(line, idx)
148✔
91
      end
92
    end
93

94
    def check_single_method_signature(line, idx)
1✔
95
      # Pattern 1: Check for colon without type (e.g., "def test():")
96
      if line.match?(/def\s+\w+[^:]*\)\s*:\s*$/)
148✔
97
        @errors << "Line #{idx + 1}: Expected type after colon, but found end of line"
2✔
98
        return
2✔
99
      end
100

101
      # Pattern 2: Check for text after closing paren without colon (e.g., "def test() something")
102
      # Use balanced paren matching to find the correct closing paren
103
      params_end = find_params_closing_paren(line)
146✔
104
      if params_end
146✔
105
        after_params = line[params_end..].strip
126✔
106
        # Check if there's trailing content that's not a return type annotation
107
        if (match = after_params.match(/^\)\s*([^:\s].+?)\s*$/))
126✔
108
          trailing = match[1].strip
2✔
109
          # Allow if it's just end-of-line content or a valid Ruby block start
110
          unless trailing.empty? || trailing.start_with?("#") || trailing == "end"
2✔
111
            @errors << "Line #{idx + 1}: Unexpected token '#{trailing}' after method parameters - did you forget ':'?"
2✔
112
          end
113
          return
2✔
114
        end
115
      end
116

117
      # Pattern 3: Check for parameter with colon but no type (e.g., "def test(x:)")
118
      # Skip this check for keyword args group { name:, age: } - they're valid
119
      params_str = extract_params_string(line)
144✔
120
      # Check each parameter for colon without type
121
      # Match: "x:" at end, "x:," in middle, or "x: )" with space before closing
122
      if params_str && !params_str.include?("{") &&
144✔
123
         (params_str.match?(/\w+:\s*$/) || params_str.match?(/\w+:\s*,/))
117✔
124
        @errors << "Line #{idx + 1}: Expected type after parameter colon"
6✔
125
        return
6✔
126
      end
127

128
      # Pattern 4: Extract and validate return type
129
      if params_end
138✔
130
        after_params = line[params_end..]
118✔
131
        if (match = after_params.match(/\)\s*:\s*(.+?)\s*$/))
118✔
132
          return_type_str = match[1].strip
103✔
133
          validate_type_expression(return_type_str, idx, "return type")
103✔
134
        end
135
      end
136

137
      # Pattern 5: Extract and validate parameter types
138
      if params_str
138✔
139
        validate_parameter_types_expression(params_str, idx)
118✔
140
      end
141
    end
142

143
    # Find the position of the closing paren for method parameters (balanced matching)
144
    def find_params_closing_paren(line)
1✔
145
      start_pos = line.index("(")
272✔
146
      return nil unless start_pos
272✔
147

148
      depth = 0
254✔
149
      line[start_pos..].each_char.with_index do |char, i|
254✔
150
        case char
2,802✔
151
        when "("
152
          depth += 1
256✔
153
        when ")"
154
          depth -= 1
252✔
155
          return start_pos + i if depth.zero?
252✔
156
        end
157
      end
158
      nil
159
    end
160

161
    # Extract the parameters string from a method definition line
162
    def extract_params_string(line)
1✔
163
      start_pos = line.index("(")
144✔
164
      return nil unless start_pos
144✔
165

166
      end_pos = find_params_closing_paren(line)
126✔
167
      return nil unless end_pos
126✔
168

169
      line[(start_pos + 1)...end_pos]
124✔
170
    end
171

172
    def validate_type_expression(type_str, line_idx, context = "type")
1✔
173
      return if type_str.nil? || type_str.empty?
167✔
174

175
      # Check for whitespace in simple type names (e.g., "Str ing")
176
      if type_str.match?(/^[A-Z][a-z]*\s+[a-z]+/)
167✔
177
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - unexpected whitespace in type name"
5✔
178
        return
5✔
179
      end
180

181
      # Check for trailing operators (e.g., "String |" or "String &")
182
      if type_str.match?(/[|&]\s*$/)
162✔
183
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - trailing operator"
3✔
184
        return
3✔
185
      end
186

187
      # Check for leading operators
188
      if type_str.match?(/^\s*[|&]/)
159✔
189
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - leading operator"
×
190
        return
×
191
      end
192

193
      # Check for double operators (e.g., "String | | Integer")
194
      if type_str.match?(/[|&]\s*[|&]/)
159✔
195
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - consecutive operators"
2✔
196
        return
2✔
197
      end
198

199
      # Check for unclosed brackets
200
      # Note: we need to exclude -> arrow operators when counting < and >
201
      angle_balance = count_angle_brackets(type_str)
157✔
202
      if angle_balance != 0
157✔
203
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - unbalanced angle brackets"
3✔
204
        return
3✔
205
      end
206

207
      if type_str.count("[") != type_str.count("]")
154✔
208
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - unbalanced square brackets"
×
209
        return
×
210
      end
211

212
      if type_str.count("(") != type_str.count(")")
154✔
213
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - unbalanced parentheses"
×
214
        return
×
215
      end
216

217
      # Check for empty generic arguments (e.g., "Array<>")
218
      if type_str.match?(/<\s*>/)
154✔
UNCOV
219
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - empty generic arguments"
×
UNCOV
220
        return
×
221
      end
222

223
      # Check for generic without base type (e.g., "<String>")
224
      if type_str.match?(/^\s*</)
154✔
225
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - missing base type for generic"
1✔
226
        return
1✔
227
      end
228

229
      # Check for missing arrow target in function type
230
      if type_str.match?(/->\s*$/)
153✔
231
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - missing return type after ->"
×
232
        return
×
233
      end
234

235
      # Check for extra tokens after valid type (e.g., "String something_else")
236
      # Use TypeParser to validate
237
      result = @type_parser.parse(type_str)
153✔
238
      return unless result[:success]
153✔
239

240
      remaining = result[:remaining]&.strip
151✔
241
      return if remaining.nil? || remaining.empty?
151✔
242

243
      # Allow RBS-style square bracket generics (e.g., Hash[Symbol, String])
244
      # Allow nullable suffix (e.g., String?)
245
      # Allow array suffix (e.g., [])
246
      return if remaining.start_with?("[") || remaining.start_with?("?") || remaining == "[]"
2✔
247

248
      @errors << "Line #{line_idx + 1}: Unexpected token after #{context} '#{type_str}'"
2✔
249
    end
250

251
    def validate_parameter_types_expression(params_str, line_idx)
1✔
252
      return if params_str.nil? || params_str.empty?
118✔
253

254
      # Split parameters handling nested generics
255
      params = split_parameters(params_str)
69✔
256

257
      params.each do |param|
69✔
258
        param = param.strip
79✔
259
        next if param.empty?
79✔
260

261
        # Skip keyword args group: { name: Type, age: Type }
262
        next if param.start_with?("{")
79✔
263

264
        # Skip block parameter: &block or &block: Type
265
        next if param.start_with?("&")
76✔
266

267
        # Skip rest parameter: *args or *args: Type
268
        next if param.start_with?("*")
76✔
269

270
        # Check for param: Type pattern (with optional default value)
271
        # Match: name: Type or name: Type = default
272
        next unless (match = param.match(/^(\w+)\s*:\s*(.+)$/))
75✔
273

274
        param_name = match[1]
64✔
275
        type_and_default = match[2].strip
64✔
276

277
        if type_and_default.empty?
64✔
278
          @errors << "Line #{line_idx + 1}: Expected type after colon for parameter '#{param_name}'"
×
279
          next
×
280
        end
281

282
        # Extract just the type part (before any '=' for default value)
283
        type_str = extract_type_from_param(type_and_default)
64✔
284
        next if type_str.nil? || type_str.empty?
64✔
285

286
        validate_type_expression(type_str, line_idx, "parameter type for '#{param_name}'")
64✔
287
      end
288
    end
289

290
    # Extract type from "Type = default_value" or just "Type"
291
    def extract_type_from_param(type_and_default)
1✔
292
      # Find the position of '=' that's not inside parentheses/brackets
293
      depth = 0
64✔
294
      type_and_default.each_char.with_index do |char, i|
64✔
295
        case char
590✔
296
        when "(", "<", "["
297
          depth += 1
8✔
298
        when ")", ">", "]"
299
          depth -= 1
8✔
300
        when "="
301
          # Make sure it's not part of -> operator
302
          prev_char = i.positive? ? type_and_default[i - 1] : nil
3✔
303
          next if %w[- ! = < >].include?(prev_char)
3✔
304

305
          return type_and_default[0...i].strip if depth.zero?
3✔
306
        end
307
      end
308
      type_and_default
61✔
309
    end
310

311
    def split_parameters(params_str)
1✔
312
      result = []
135✔
313
      current = ""
135✔
314
      paren_depth = 0
135✔
315
      bracket_depth = 0
135✔
316
      angle_depth = 0
135✔
317
      brace_depth = 0
135✔
318

319
      i = 0
135✔
320
      while i < params_str.length
135✔
321
        char = params_str[i]
2,142✔
322
        next_char = params_str[i + 1]
2,142✔
323
        prev_char = i.positive? ? params_str[i - 1] : nil
2,142✔
324

325
        case char
2,142✔
326
        when "("
327
          paren_depth += 1
2✔
328
          current += char
2✔
329
        when ")"
330
          paren_depth -= 1
2✔
331
          current += char
2✔
332
        when "["
NEW
333
          bracket_depth += 1
×
NEW
334
          current += char
×
335
        when "]"
NEW
336
          bracket_depth -= 1
×
NEW
337
          current += char
×
338
        when "<"
339
          # Only count as generic if it's not part of operator like <=, <=>
340
          if next_char != "=" && next_char != ">"
14✔
341
            angle_depth += 1
14✔
342
          end
343
          current += char
14✔
344
        when ">"
345
          # Only count as closing generic if we're inside a generic (angle_depth > 0)
346
          # and it's not part of -> operator
347
          if angle_depth.positive? && prev_char != "-"
18✔
348
            angle_depth -= 1
12✔
349
          end
350
          current += char
18✔
351
        when "{"
352
          brace_depth += 1
14✔
353
          current += char
14✔
354
        when "}"
355
          brace_depth -= 1
14✔
356
          current += char
14✔
357
        when ","
358
          if paren_depth.zero? && bracket_depth.zero? && angle_depth.zero? && brace_depth.zero?
31✔
359
            result << current.strip
19✔
360
            current = ""
19✔
361
          else
362
            current += char
12✔
363
          end
364
        else
365
          current += char
2,047✔
366
        end
367
        i += 1
2,142✔
368
      end
369

370
      result << current.strip unless current.empty?
135✔
371
      result
135✔
372
    end
373

374
    def check_type_validation
1✔
375
      @lines.each_with_index do |line, idx|
172✔
376
        next unless line.match?(/^\s*def\s+/)
507✔
377

378
        # Extract types from function definition - now handle complex types
379
        match = line.match(/def\s+\w+\s*\((.*?)\)\s*(?::\s*(.+?))?$/)
148✔
380
        next unless match
148✔
381

382
        params_str = match[1]
113✔
383
        return_type = match[2]&.strip
113✔
384

385
        # Check return type if it's a simple type name
386
        if return_type&.match?(/^\w+$/) && !(VALID_TYPES.include?(return_type) || @type_aliases.key?(return_type) || @interfaces.key?(return_type))
113✔
387
          @errors << "Line #{idx + 1}: Unknown return type '#{return_type}'"
2✔
388
        end
389

390
        # Check parameter types
391
        check_parameter_types(params_str, idx)
113✔
392
      end
393
    end
394

395
    def check_parameter_types(params_str, line_idx)
1✔
396
      return if params_str.nil? || params_str.empty?
113✔
397

398
      params = split_parameters(params_str)
66✔
399
      params.each do |param|
66✔
400
        param = param.strip
75✔
401
        match = param.match(/^(\w+)(?::\s*(.+))?$/)
75✔
402
        next unless match
75✔
403

404
        param_type = match[2]&.strip
62✔
405
        next unless param_type
62✔
406

407
        # Only check simple type names against VALID_TYPES
408
        next unless param_type.match?(/^\w+$/)
59✔
409
        next if VALID_TYPES.include?(param_type) || @type_aliases.key?(param_type) || @interfaces.key?(param_type)
43✔
410

411
        @errors << "Line #{line_idx + 1}: Unknown parameter type '#{param_type}'"
7✔
412
      end
413
    end
414

415
    def check_duplicate_definitions
1✔
416
      current_class = nil
172✔
417
      class_methods = {} # { class_name => { method_name => line_number } }
172✔
418

419
      @lines.each_with_index do |line, idx|
172✔
420
        # Track class context
421
        if line.match?(/^\s*class\s+(\w+)/)
507✔
422
          current_class = line.match(/class\s+(\w+)/)[1]
5✔
423
          class_methods[current_class] ||= {}
5✔
424
        elsif line.match?(/^\s*end\s*$/) && current_class
502✔
425
          # Simple heuristic: top-level 'end' closes current class
426
          # This is imperfect but handles most cases
427
          current_class = nil if line.match?(/^end\s*$/)
11✔
428
        end
429

430
        # Use unicode-aware pattern for function names (supports Korean, etc.)
431
        next unless line.match?(/^\s*def\s+#{IDENTIFIER_PATTERN}/)
507✔
432

433
        func_name = line.match(/def\s+(#{IDENTIFIER_PATTERN})/)[1]
147✔
434

435
        if current_class
147✔
436
          # Method inside a class - check within class scope
437
          methods = class_methods[current_class]
6✔
438
          if methods[func_name]
6✔
439
            @errors << "Line #{idx + 1}: Function '#{func_name}' is already defined at line #{methods[func_name]}"
1✔
440
          else
441
            methods[func_name] = idx + 1
5✔
442
          end
443
        elsif @functions[func_name]
141✔
444
          # Top-level function - check global scope
445
          @errors << "Line #{idx + 1}: Function '#{func_name}' is already defined at line #{@functions[func_name]}"
4✔
446
        else
447
          @functions[func_name] = idx + 1
137✔
448
        end
449
      end
450
    end
451

452
    # Count angle brackets excluding those in -> arrow operators
453
    # Returns the balance (positive if more <, negative if more >)
454
    def count_angle_brackets(type_str)
1✔
455
      balance = 0
157✔
456
      i = 0
157✔
457
      while i < type_str.length
157✔
458
        char = type_str[i]
1,196✔
459
        prev_char = i.positive? ? type_str[i - 1] : nil
1,196✔
460
        next_char = type_str[i + 1]
1,196✔
461

462
        case char
1,196✔
463
        when "<"
464
          # Skip if it's part of <= or <>
465
          balance += 1 unless %w[= >].include?(next_char)
12✔
466
        when ">"
467
          # Skip if it's part of -> arrow operator
468
          balance -= 1 unless prev_char == "-"
12✔
469
        end
470
        i += 1
1,196✔
471
      end
472
      balance
157✔
473
    end
474
  end
475
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

© 2025 Coveralls, Inc