• 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

88.0
/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
137✔
11
      @lines = source.split("\n")
137✔
12
      @errors = []
137✔
13
      @functions = {}
137✔
14
      @type_parser = ParserCombinator::TypeParser.new
137✔
15
    end
16

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

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

30
      @errors
137✔
31
    end
32

33
    private
1✔
34

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

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

42
        interface_name = match[1]
25✔
43

44
        if @interfaces[interface_name]
25✔
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
23✔
48
        end
49
      end
50
    end
51

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

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

59
        alias_name = match[1]
14✔
60

61
        if @type_aliases[alias_name]
14✔
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
11✔
65
        end
66
      end
67
    end
68

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

73
        # Check for unclosed parenthesis
74
        if line.match?(/def\s+\w+\([^)]*$/) && @lines[(idx + 1)..].none? { |l| l.match?(/\)/) }
122✔
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+/)
120✔
80
          @errors << "Line #{idx + 1}: Invalid parameter syntax - parameter name missing"
2✔
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|
137✔
88
        next unless line.match?(/^\s*def\s+/)
421✔
89

90
        check_single_method_signature(line, idx)
120✔
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*$/)
120✔
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)
118✔
104
      if params_end
118✔
105
        after_params = line[params_end..].strip
107✔
106
        # Check if there's trailing content that's not a return type annotation
107
        if (match = after_params.match(/^\)\s*([^:\s].+?)\s*$/))
107✔
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)
116✔
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?("{") &&
116✔
123
         (params_str.match?(/\w+:\s*$/) || params_str.match?(/\w+:\s*,/))
100✔
124
        @errors << "Line #{idx + 1}: Expected type after parameter colon"
5✔
125
        return
5✔
126
      end
127

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

137
      # Pattern 5: Extract and validate parameter types
138
      if params_str
111✔
139
        validate_parameter_types_expression(params_str, idx)
100✔
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("(")
225✔
146
      return nil unless start_pos
225✔
147

148
      depth = 0
216✔
149
      line[start_pos..].each_char.with_index do |char, i|
216✔
150
        case char
2,140✔
151
        when "("
152
          depth += 1
216✔
153
        when ")"
154
          depth -= 1
212✔
155
          return start_pos + i if depth.zero?
212✔
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("(")
116✔
164
      return nil unless start_pos
116✔
165

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

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

172
    def validate_type_expression(type_str, line_idx, context = "type")
1✔
173
      return if type_str.nil? || type_str.empty?
133✔
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]+/)
133✔
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*$/)
128✔
183
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - trailing operator"
1✔
184
        return
1✔
185
      end
186

187
      # Check for leading operators
188
      if type_str.match?(/^\s*[|&]/)
127✔
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*[|&]/)
127✔
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)
125✔
202
      if angle_balance != 0
125✔
203
        @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - unbalanced angle brackets"
2✔
204
        return
2✔
205
      end
206

207
      if type_str.count("[") != type_str.count("]")
123✔
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(")")
123✔
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*>/)
123✔
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*</)
123✔
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*$/)
122✔
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)
122✔
238
      return unless result[:success]
122✔
239

240
      remaining = result[:remaining]&.strip
120✔
241
      return if remaining.nil? || remaining.empty?
120✔
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., [])
NEW
246
      return if remaining.start_with?("[") || remaining.start_with?("?") || remaining == "[]"
×
247

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

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

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

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

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

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

267
        # Skip rest parameter: *args or *args: Type
268
        next if param.start_with?("*")
59✔
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*(.+)$/))
58✔
273

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

277
        if type_and_default.empty?
48✔
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)
48✔
284
        next if type_str.nil? || type_str.empty?
48✔
285

286
        validate_type_expression(type_str, line_idx, "parameter type for '#{param_name}'")
48✔
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
48✔
294
      type_and_default.each_char.with_index do |char, i|
48✔
295
        case char
408✔
296
        when "(", "<", "["
NEW
297
          depth += 1
×
298
        when ")", ">", "]"
NEW
299
          depth -= 1
×
300
        when "="
301
          # Make sure it's not part of -> operator
302
          prev_char = i.positive? ? type_and_default[i - 1] : nil
1✔
303
          next if %w[- ! = < >].include?(prev_char)
1✔
304

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

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

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

325
        case char
1,600✔
326
        when "("
NEW
327
          paren_depth += 1
×
UNCOV
328
          current += char
×
329
        when ")"
NEW
330
          paren_depth -= 1
×
NEW
331
          current += char
×
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 <=, <=>
NEW
340
          if next_char != "=" && next_char != ">"
×
NEW
341
            angle_depth += 1
×
342
          end
NEW
343
          current += char
×
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
NEW
347
          if angle_depth.positive? && prev_char != "-"
×
NEW
348
            angle_depth -= 1
×
349
          end
NEW
350
          current += char
×
351
        when "{"
352
          brace_depth += 1
10✔
353
          current += char
10✔
354
        when "}"
355
          brace_depth -= 1
10✔
356
          current += char
10✔
357
        when ","
358
          if paren_depth.zero? && bracket_depth.zero? && angle_depth.zero? && brace_depth.zero?
24✔
359
            result << current.strip
14✔
360
            current = ""
14✔
361
          else
362
            current += char
10✔
363
          end
364
        else
365
          current += char
1,556✔
366
        end
367
        i += 1
1,600✔
368
      end
369

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

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

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

382
        params_str = match[1]
97✔
383
        return_type = match[2]&.strip
97✔
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))
97✔
387
          @errors << "Line #{idx + 1}: Unknown return type '#{return_type}'"
1✔
388
        end
389

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

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

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

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

407
        # Only check simple type names against VALID_TYPES
408
        next unless param_type.match?(/^\w+$/)
47✔
409
        next if VALID_TYPES.include?(param_type) || @type_aliases.key?(param_type) || @interfaces.key?(param_type)
41✔
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
137✔
417
      class_methods = {} # { class_name => { method_name => line_number } }
137✔
418

419
      @lines.each_with_index do |line, idx|
137✔
420
        # Track class context
421
        if line.match?(/^\s*class\s+(\w+)/)
421✔
422
          current_class = line.match(/class\s+(\w+)/)[1]
3✔
423
          class_methods[current_class] ||= {}
3✔
424
        elsif line.match?(/^\s*end\s*$/) && current_class
418✔
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*$/)
7✔
428
        end
429

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

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

435
        if current_class
119✔
436
          # Method inside a class - check within class scope
437
          methods = class_methods[current_class]
4✔
438
          if methods[func_name]
4✔
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
3✔
442
          end
443
        elsif @functions[func_name]
115✔
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
111✔
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
125✔
456
      i = 0
125✔
457
      while i < type_str.length
125✔
458
        char = type_str[i]
906✔
459
        prev_char = i.positive? ? type_str[i - 1] : nil
906✔
460
        next_char = type_str[i + 1]
906✔
461

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