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

type-ruby / t-ruby / 20569360964

29 Dec 2025 09:21AM UTC coverage: 78.823% (-0.3%) from 79.076%
20569360964

Pull #30

github

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

426 of 636 new or added lines in 14 files covered. (66.98%)

12 existing lines in 4 files now uncovered.

7005 of 8887 relevant lines covered (78.82%)

882.36 hits per line

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

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

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

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

30
      @errors
115✔
31
    end
32

33
    private
1✔
34

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

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

42
        interface_name = match[1]
24✔
43

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

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

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

59
        alias_name = match[1]
10✔
60

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

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

73
        # Check for unclosed parenthesis
74
        if line.match?(/def\s+\w+\([^)]*$/) && @lines[(idx + 1)..].none? { |l| l.match?(/\)/) }
99✔
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+/)
97✔
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|
115✔
88
        next unless line.match?(/^\s*def\s+/)
335✔
89

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

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

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

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

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

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

172
    def validate_type_expression(type_str, line_idx, context = "type")
1✔
173
      return if type_str.nil? || type_str.empty?
105✔
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]+/)
105✔
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*$/)
100✔
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*[|&]/)
99✔
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*[|&]/)
99✔
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)
97✔
202
      if angle_balance != 0
97✔
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("]")
95✔
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(")")
95✔
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*>/)
95✔
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*</)
95✔
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*$/)
94✔
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)
94✔
238
      return unless result[:success]
94✔
239

240
      remaining = result[:remaining]&.strip
94✔
241
      return if remaining.nil? || remaining.empty?
94✔
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?
78✔
253

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

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

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

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

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

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

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

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

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

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

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

325
        case char
1,045✔
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 "{"
NEW
352
          brace_depth += 1
×
NEW
353
          current += char
×
354
        when "}"
NEW
355
          brace_depth -= 1
×
UNCOV
356
          current += char
×
357
        when ","
358
          if paren_depth.zero? && bracket_depth.zero? && angle_depth.zero? && brace_depth.zero?
12✔
359
            result << current.strip
12✔
360
            current = ""
12✔
361
          else
362
            current += char
×
363
          end
364
        else
365
          current += char
1,033✔
366
        end
367
        i += 1
1,045✔
368
      end
369

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

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

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

382
        params_str = match[1]
79✔
383
        return_type = match[2]&.strip
79✔
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))
79✔
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)
79✔
392
      end
393
    end
394

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

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

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

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

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

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

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

435
        if current_class
96✔
436
          # Method inside a class - check within class scope
NEW
437
          methods = class_methods[current_class]
×
NEW
438
          if methods[func_name]
×
NEW
439
            @errors << "Line #{idx + 1}: Function '#{func_name}' is already defined at line #{methods[func_name]}"
×
440
          else
NEW
441
            methods[func_name] = idx + 1
×
442
          end
443
        elsif @functions[func_name]
96✔
444
          # Top-level function - check global scope
445
          @errors << "Line #{idx + 1}: Function '#{func_name}' is already defined at line #{@functions[func_name]}"
3✔
446
        else
447
          @functions[func_name] = idx + 1
93✔
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
97✔
456
      i = 0
97✔
457
      while i < type_str.length
97✔
458
        char = type_str[i]
681✔
459
        prev_char = i.positive? ? type_str[i - 1] : nil
681✔
460
        next_char = type_str[i + 1]
681✔
461

462
        case char
681✔
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
681✔
471
      end
472
      balance
97✔
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