t-ruby 0.0.41 → 0.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/t_ruby/ast_type_inferrer.rb +2 -0
  3. data/lib/t_ruby/cache.rb +40 -10
  4. data/lib/t_ruby/cli.rb +14 -9
  5. data/lib/t_ruby/code_emitter.rb +254 -0
  6. data/lib/t_ruby/compiler.rb +186 -3
  7. data/lib/t_ruby/config.rb +18 -3
  8. data/lib/t_ruby/diagnostic.rb +115 -0
  9. data/lib/t_ruby/diagnostic_formatter.rb +162 -0
  10. data/lib/t_ruby/error_handler.rb +201 -35
  11. data/lib/t_ruby/error_reporter.rb +57 -0
  12. data/lib/t_ruby/ir.rb +39 -1
  13. data/lib/t_ruby/lsp_server.rb +40 -97
  14. data/lib/t_ruby/parser.rb +18 -4
  15. data/lib/t_ruby/parser_combinator/combinators/alternative.rb +20 -0
  16. data/lib/t_ruby/parser_combinator/combinators/chain_left.rb +34 -0
  17. data/lib/t_ruby/parser_combinator/combinators/choice.rb +29 -0
  18. data/lib/t_ruby/parser_combinator/combinators/flat_map.rb +21 -0
  19. data/lib/t_ruby/parser_combinator/combinators/label.rb +22 -0
  20. data/lib/t_ruby/parser_combinator/combinators/lookahead.rb +21 -0
  21. data/lib/t_ruby/parser_combinator/combinators/many.rb +29 -0
  22. data/lib/t_ruby/parser_combinator/combinators/many1.rb +32 -0
  23. data/lib/t_ruby/parser_combinator/combinators/map.rb +17 -0
  24. data/lib/t_ruby/parser_combinator/combinators/not_followed_by.rb +21 -0
  25. data/lib/t_ruby/parser_combinator/combinators/optional.rb +21 -0
  26. data/lib/t_ruby/parser_combinator/combinators/sep_by.rb +34 -0
  27. data/lib/t_ruby/parser_combinator/combinators/sep_by1.rb +34 -0
  28. data/lib/t_ruby/parser_combinator/combinators/sequence.rb +23 -0
  29. data/lib/t_ruby/parser_combinator/combinators/skip_right.rb +23 -0
  30. data/lib/t_ruby/parser_combinator/declaration_parser.rb +147 -0
  31. data/lib/t_ruby/parser_combinator/dsl.rb +115 -0
  32. data/lib/t_ruby/parser_combinator/parse_error.rb +48 -0
  33. data/lib/t_ruby/parser_combinator/parse_result.rb +46 -0
  34. data/lib/t_ruby/parser_combinator/parser.rb +84 -0
  35. data/lib/t_ruby/parser_combinator/primitives/end_of_input.rb +16 -0
  36. data/lib/t_ruby/parser_combinator/primitives/fail.rb +16 -0
  37. data/lib/t_ruby/parser_combinator/primitives/lazy.rb +18 -0
  38. data/lib/t_ruby/parser_combinator/primitives/literal.rb +21 -0
  39. data/lib/t_ruby/parser_combinator/primitives/pure.rb +16 -0
  40. data/lib/t_ruby/parser_combinator/primitives/regex.rb +25 -0
  41. data/lib/t_ruby/parser_combinator/primitives/satisfy.rb +21 -0
  42. data/lib/t_ruby/parser_combinator/token/expression_parser.rb +541 -0
  43. data/lib/t_ruby/parser_combinator/token/statement_parser.rb +644 -0
  44. data/lib/t_ruby/parser_combinator/token/token_alternative.rb +20 -0
  45. data/lib/t_ruby/parser_combinator/token/token_body_parser.rb +54 -0
  46. data/lib/t_ruby/parser_combinator/token/token_declaration_parser.rb +920 -0
  47. data/lib/t_ruby/parser_combinator/token/token_dsl.rb +16 -0
  48. data/lib/t_ruby/parser_combinator/token/token_label.rb +22 -0
  49. data/lib/t_ruby/parser_combinator/token/token_many.rb +29 -0
  50. data/lib/t_ruby/parser_combinator/token/token_many1.rb +32 -0
  51. data/lib/t_ruby/parser_combinator/token/token_map.rb +17 -0
  52. data/lib/t_ruby/parser_combinator/token/token_matcher.rb +29 -0
  53. data/lib/t_ruby/parser_combinator/token/token_optional.rb +21 -0
  54. data/lib/t_ruby/parser_combinator/token/token_parse_result.rb +40 -0
  55. data/lib/t_ruby/parser_combinator/token/token_parser.rb +62 -0
  56. data/lib/t_ruby/parser_combinator/token/token_sep_by.rb +34 -0
  57. data/lib/t_ruby/parser_combinator/token/token_sep_by1.rb +34 -0
  58. data/lib/t_ruby/parser_combinator/token/token_sequence.rb +23 -0
  59. data/lib/t_ruby/parser_combinator/token/token_skip_right.rb +23 -0
  60. data/lib/t_ruby/parser_combinator/type_parser.rb +103 -0
  61. data/lib/t_ruby/parser_combinator.rb +64 -936
  62. data/lib/t_ruby/ruby_version.rb +112 -0
  63. data/lib/t_ruby/scanner.rb +883 -0
  64. data/lib/t_ruby/version.rb +1 -1
  65. data/lib/t_ruby/watcher.rb +83 -76
  66. data/lib/t_ruby.rb +17 -1
  67. metadata +58 -7
  68. data/lib/t_ruby/body_parser.rb +0 -561
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRuby
4
+ class Diagnostic
5
+ SEVERITY_ERROR = :error
6
+ SEVERITY_WARNING = :warning
7
+ SEVERITY_INFO = :info
8
+ SEVERITY_HINT = :hint
9
+
10
+ attr_reader :code, :message, :file, :line, :column, :end_column,
11
+ :severity, :expected, :actual, :suggestion, :source_line
12
+
13
+ # rubocop:disable Metrics/ParameterLists
14
+ def initialize(
15
+ code:,
16
+ message:,
17
+ file: nil,
18
+ line: nil,
19
+ column: nil,
20
+ end_column: nil,
21
+ severity: SEVERITY_ERROR,
22
+ expected: nil,
23
+ actual: nil,
24
+ suggestion: nil,
25
+ source_line: nil
26
+ )
27
+ # rubocop:enable Metrics/ParameterLists
28
+ @code = code
29
+ @message = message
30
+ @file = file
31
+ @line = line
32
+ @column = column || 1
33
+ @end_column = end_column || (@column + 1)
34
+ @severity = severity
35
+ @expected = expected
36
+ @actual = actual
37
+ @suggestion = suggestion
38
+ @source_line = source_line
39
+ end
40
+
41
+ def self.from_type_check_error(error, file: nil, source: nil)
42
+ line, col = parse_location(error.location)
43
+ source_line = extract_source_line(source, line) if source && line
44
+
45
+ new(
46
+ code: "TR2001",
47
+ message: error.error_message,
48
+ file: file,
49
+ line: line,
50
+ column: col,
51
+ severity: error.severity || SEVERITY_ERROR,
52
+ expected: error.expected,
53
+ actual: error.actual,
54
+ suggestion: error.suggestion,
55
+ source_line: source_line
56
+ )
57
+ end
58
+
59
+ def self.from_parse_error(error, file: nil, source: nil)
60
+ source_line = extract_source_line(source, error.line) if source && error.line
61
+
62
+ new(
63
+ code: "TR1001",
64
+ message: error.message,
65
+ file: file,
66
+ line: error.line,
67
+ column: error.column,
68
+ source_line: source_line
69
+ )
70
+ end
71
+
72
+ def self.from_scan_error(error, file: nil, source: nil)
73
+ source_line = extract_source_line(source, error.line) if source && error.line
74
+ # ScanError adds " at line X, column Y" to the message in its constructor
75
+ message = error.message.sub(/ at line \d+, column \d+\z/, "")
76
+
77
+ new(
78
+ code: "TR1001",
79
+ message: message,
80
+ file: file,
81
+ line: error.line,
82
+ column: error.column,
83
+ source_line: source_line
84
+ )
85
+ end
86
+
87
+ def error?
88
+ @severity == SEVERITY_ERROR
89
+ end
90
+
91
+ def self.parse_location(location_str)
92
+ return [nil, 1] unless location_str
93
+
94
+ case location_str
95
+ when /:(\d+):(\d+)$/
96
+ [::Regexp.last_match(1).to_i, ::Regexp.last_match(2).to_i]
97
+ when /:(\d+)$/
98
+ [::Regexp.last_match(1).to_i, 1]
99
+ when /line (\d+)/i
100
+ [::Regexp.last_match(1).to_i, 1]
101
+ else
102
+ [nil, 1]
103
+ end
104
+ end
105
+
106
+ def self.extract_source_line(source, line_num)
107
+ return nil unless source && line_num
108
+
109
+ lines = source.split("\n")
110
+ lines[line_num - 1] if line_num.positive? && line_num <= lines.length
111
+ end
112
+
113
+ private_class_method :parse_location, :extract_source_line
114
+ end
115
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRuby
4
+ class DiagnosticFormatter
5
+ COLORS = {
6
+ reset: "\e[0m",
7
+ bold: "\e[1m",
8
+ dim: "\e[2m",
9
+ red: "\e[31m",
10
+ green: "\e[32m",
11
+ yellow: "\e[33m",
12
+ blue: "\e[34m",
13
+ cyan: "\e[36m",
14
+ gray: "\e[90m",
15
+ white: "\e[37m",
16
+ }.freeze
17
+
18
+ def initialize(use_colors: nil)
19
+ @use_colors = use_colors.nil? ? $stdout.tty? : use_colors
20
+ end
21
+
22
+ def format(diagnostic)
23
+ lines = []
24
+
25
+ lines << format_header(diagnostic)
26
+
27
+ if diagnostic.source_line && diagnostic.line
28
+ lines << ""
29
+ lines << format_source_snippet(diagnostic)
30
+ lines << format_marker(diagnostic)
31
+ lines.concat(format_context(diagnostic))
32
+ end
33
+
34
+ lines.join("\n")
35
+ end
36
+
37
+ def format_all(diagnostics)
38
+ return "" if diagnostics.empty?
39
+
40
+ output = diagnostics.map { |d| format(d) }.join("\n\n")
41
+ "#{output}\n\n#{format_summary(diagnostics)}"
42
+ end
43
+
44
+ private
45
+
46
+ def format_header(diagnostic)
47
+ location = format_location(diagnostic)
48
+ severity_text = colorize(severity_color(diagnostic.severity), diagnostic.severity.to_s)
49
+ code_text = colorize(:gray, diagnostic.code)
50
+
51
+ "#{location} - #{severity_text} #{code_text}: #{diagnostic.message}"
52
+ end
53
+
54
+ def format_location(diagnostic)
55
+ file_part = colorize(:cyan, diagnostic.file || "<unknown>")
56
+
57
+ if diagnostic.line
58
+ line_part = colorize(:yellow, diagnostic.line.to_s)
59
+ col_part = colorize(:yellow, diagnostic.column.to_s)
60
+ "#{file_part}:#{line_part}:#{col_part}"
61
+ else
62
+ file_part
63
+ end
64
+ end
65
+
66
+ def format_source_snippet(diagnostic)
67
+ line_num = diagnostic.line.to_s.rjust(4)
68
+ line_num_colored = colorize(:gray, line_num)
69
+
70
+ "#{line_num_colored} | #{diagnostic.source_line}"
71
+ end
72
+
73
+ def format_marker(diagnostic)
74
+ col = diagnostic.column || 1
75
+ width = calculate_marker_width(diagnostic)
76
+
77
+ indent = "#{" " * 4} | #{" " * (col - 1)}"
78
+ marker = colorize(:red, "~" * width)
79
+
80
+ "#{indent}#{marker}"
81
+ end
82
+
83
+ def calculate_marker_width(diagnostic)
84
+ # If end_column is explicitly set (not just default column + 1), use it
85
+ if diagnostic.end_column && diagnostic.end_column > diagnostic.column + 1
86
+ diagnostic.end_column - diagnostic.column
87
+ elsif diagnostic.source_line
88
+ # Try to guess width from identifier at error position
89
+ remaining = diagnostic.source_line[(diagnostic.column - 1)..]
90
+ if remaining && remaining =~ /^(\w+)/
91
+ ::Regexp.last_match(1).length
92
+ else
93
+ 1
94
+ end
95
+ else
96
+ 1
97
+ end
98
+ end
99
+
100
+ def format_context(diagnostic)
101
+ lines = []
102
+ indent = "#{" " * 4} | "
103
+
104
+ if diagnostic.expected
105
+ label = colorize(:dim, "Expected:")
106
+ value = colorize(:green, diagnostic.expected)
107
+ lines << "#{indent}#{label} #{value}"
108
+ end
109
+
110
+ if diagnostic.actual
111
+ label = colorize(:dim, "Actual:")
112
+ value = colorize(:red, diagnostic.actual)
113
+ lines << "#{indent}#{label} #{value}"
114
+ end
115
+
116
+ if diagnostic.suggestion
117
+ label = colorize(:dim, "Suggestion:")
118
+ lines << "#{indent}#{label} #{diagnostic.suggestion}"
119
+ end
120
+
121
+ lines
122
+ end
123
+
124
+ def format_summary(diagnostics)
125
+ error_count = diagnostics.count { |d| d.severity == Diagnostic::SEVERITY_ERROR }
126
+ warning_count = diagnostics.count { |d| d.severity == Diagnostic::SEVERITY_WARNING }
127
+
128
+ parts = []
129
+
130
+ if error_count.positive?
131
+ error_word = error_count == 1 ? "error" : "errors"
132
+ parts << colorize(:red, "#{error_count} #{error_word}")
133
+ end
134
+
135
+ if warning_count.positive?
136
+ warning_word = warning_count == 1 ? "warning" : "warnings"
137
+ parts << colorize(:yellow, "#{warning_count} #{warning_word}")
138
+ end
139
+
140
+ if parts.empty?
141
+ colorize(:green, "No errors found.")
142
+ else
143
+ "Found #{parts.join(" and ")}."
144
+ end
145
+ end
146
+
147
+ def severity_color(severity)
148
+ case severity
149
+ when :error then :red
150
+ when :warning then :yellow
151
+ else :white
152
+ end
153
+ end
154
+
155
+ def colorize(color, text)
156
+ return text.to_s unless @use_colors
157
+ return text.to_s unless COLORS[color]
158
+
159
+ "#{COLORS[color]}#{text}#{COLORS[:reset]}"
160
+ end
161
+ end
162
+ end
@@ -2,7 +2,9 @@
2
2
 
3
3
  module TRuby
4
4
  class ErrorHandler
5
- VALID_TYPES = %w[String Integer Boolean Array Hash Symbol void nil].freeze
5
+ VALID_TYPES = %w[String Integer Float Boolean Array Hash Symbol void nil].freeze
6
+ # Unicode-aware identifier pattern for method/variable names (supports Korean, etc.)
7
+ IDENTIFIER_PATTERN = /[\w\p{L}\p{N}]+[!?]?/
6
8
 
7
9
  def initialize(source)
8
10
  @source = source
@@ -97,34 +99,76 @@ module TRuby
97
99
  end
98
100
 
99
101
  # Pattern 2: Check for text after closing paren without colon (e.g., "def test() something")
100
- if (match = line.match(/def\s+\w+\s*\([^)]*\)\s*([^:\s].+?)\s*$/))
101
- trailing = match[1].strip
102
- # Allow if it's just end-of-line content or a valid Ruby block start
103
- unless trailing.empty? || trailing.start_with?("#") || trailing == "end"
104
- @errors << "Line #{idx + 1}: Unexpected token '#{trailing}' after method parameters - did you forget ':'?"
102
+ # Use balanced paren matching to find the correct closing paren
103
+ params_end = find_params_closing_paren(line)
104
+ if params_end
105
+ after_params = line[params_end..].strip
106
+ # Check if there's trailing content that's not a return type annotation
107
+ if (match = after_params.match(/^\)\s*([^:\s].+?)\s*$/))
108
+ trailing = match[1].strip
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"
111
+ @errors << "Line #{idx + 1}: Unexpected token '#{trailing}' after method parameters - did you forget ':'?"
112
+ end
113
+ return
105
114
  end
106
- return
107
115
  end
108
116
 
109
117
  # Pattern 3: Check for parameter with colon but no type (e.g., "def test(x:)")
110
- if line.match?(/def\s+\w+\s*\([^)]*\w+:\s*[,)]/)
118
+ # Skip this check for keyword args group { name:, age: } - they're valid
119
+ params_str = extract_params_string(line)
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?("{") &&
123
+ (params_str.match?(/\w+:\s*$/) || params_str.match?(/\w+:\s*,/))
111
124
  @errors << "Line #{idx + 1}: Expected type after parameter colon"
112
125
  return
113
126
  end
114
127
 
115
128
  # Pattern 4: Extract and validate return type
116
- if (match = line.match(/def\s+\w+\s*\([^)]*\)\s*:\s*(.+?)\s*$/))
117
- return_type_str = match[1].strip
118
- validate_type_expression(return_type_str, idx, "return type")
129
+ if params_end
130
+ after_params = line[params_end..]
131
+ if (match = after_params.match(/\)\s*:\s*(.+?)\s*$/))
132
+ return_type_str = match[1].strip
133
+ validate_type_expression(return_type_str, idx, "return type")
134
+ end
119
135
  end
120
136
 
121
137
  # Pattern 5: Extract and validate parameter types
122
- if (match = line.match(/def\s+\w+\s*\(([^)]+)\)/))
123
- params_str = match[1]
138
+ if params_str
124
139
  validate_parameter_types_expression(params_str, idx)
125
140
  end
126
141
  end
127
142
 
143
+ # Find the position of the closing paren for method parameters (balanced matching)
144
+ def find_params_closing_paren(line)
145
+ start_pos = line.index("(")
146
+ return nil unless start_pos
147
+
148
+ depth = 0
149
+ line[start_pos..].each_char.with_index do |char, i|
150
+ case char
151
+ when "("
152
+ depth += 1
153
+ when ")"
154
+ depth -= 1
155
+ return start_pos + i if depth.zero?
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)
163
+ start_pos = line.index("(")
164
+ return nil unless start_pos
165
+
166
+ end_pos = find_params_closing_paren(line)
167
+ return nil unless end_pos
168
+
169
+ line[(start_pos + 1)...end_pos]
170
+ end
171
+
128
172
  def validate_type_expression(type_str, line_idx, context = "type")
129
173
  return if type_str.nil? || type_str.empty?
130
174
 
@@ -153,7 +197,9 @@ module TRuby
153
197
  end
154
198
 
155
199
  # Check for unclosed brackets
156
- if type_str.count("<") != type_str.count(">")
200
+ # Note: we need to exclude -> arrow operators when counting < and >
201
+ angle_balance = count_angle_brackets(type_str)
202
+ if angle_balance != 0
157
203
  @errors << "Line #{line_idx + 1}: Invalid #{context} '#{type_str}' - unbalanced angle brackets"
158
204
  return
159
205
  end
@@ -189,12 +235,17 @@ module TRuby
189
235
  # Check for extra tokens after valid type (e.g., "String something_else")
190
236
  # Use TypeParser to validate
191
237
  result = @type_parser.parse(type_str)
192
- if result[:success]
193
- remaining = type_str[(result[:position] || 0)..]&.strip
194
- if remaining && !remaining.empty? && result[:remaining] && !result[:remaining].strip.empty?
195
- @errors << "Line #{line_idx + 1}: Unexpected token after #{context} '#{type_str}'"
196
- end
197
- end
238
+ return unless result[:success]
239
+
240
+ remaining = result[:remaining]&.strip
241
+ return if remaining.nil? || remaining.empty?
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 == "[]"
247
+
248
+ @errors << "Line #{line_idx + 1}: Unexpected token after #{context} '#{type_str}'"
198
249
  end
199
250
 
200
251
  def validate_parameter_types_expression(params_str, line_idx)
@@ -207,36 +258,104 @@ module TRuby
207
258
  param = param.strip
208
259
  next if param.empty?
209
260
 
210
- # Check for param: Type pattern
261
+ # Skip keyword args group: { name: Type, age: Type }
262
+ next if param.start_with?("{")
263
+
264
+ # Skip block parameter: &block or &block: Type
265
+ next if param.start_with?("&")
266
+
267
+ # Skip rest parameter: *args or *args: Type
268
+ next if param.start_with?("*")
269
+
270
+ # Check for param: Type pattern (with optional default value)
271
+ # Match: name: Type or name: Type = default
211
272
  next unless (match = param.match(/^(\w+)\s*:\s*(.+)$/))
212
273
 
213
274
  param_name = match[1]
214
- type_str = match[2].strip
275
+ type_and_default = match[2].strip
215
276
 
216
- if type_str.empty?
277
+ if type_and_default.empty?
217
278
  @errors << "Line #{line_idx + 1}: Expected type after colon for parameter '#{param_name}'"
218
279
  next
219
280
  end
220
281
 
282
+ # Extract just the type part (before any '=' for default value)
283
+ type_str = extract_type_from_param(type_and_default)
284
+ next if type_str.nil? || type_str.empty?
285
+
221
286
  validate_type_expression(type_str, line_idx, "parameter type for '#{param_name}'")
222
287
  end
223
288
  end
224
289
 
290
+ # Extract type from "Type = default_value" or just "Type"
291
+ def extract_type_from_param(type_and_default)
292
+ # Find the position of '=' that's not inside parentheses/brackets
293
+ depth = 0
294
+ type_and_default.each_char.with_index do |char, i|
295
+ case char
296
+ when "(", "<", "["
297
+ depth += 1
298
+ when ")", ">", "]"
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
303
+ next if %w[- ! = < >].include?(prev_char)
304
+
305
+ return type_and_default[0...i].strip if depth.zero?
306
+ end
307
+ end
308
+ type_and_default
309
+ end
310
+
225
311
  def split_parameters(params_str)
226
312
  result = []
227
313
  current = ""
228
- depth = 0
314
+ paren_depth = 0
315
+ bracket_depth = 0
316
+ angle_depth = 0
317
+ brace_depth = 0
318
+
319
+ i = 0
320
+ while i < params_str.length
321
+ char = params_str[i]
322
+ next_char = params_str[i + 1]
323
+ prev_char = i.positive? ? params_str[i - 1] : nil
229
324
 
230
- params_str.each_char do |char|
231
325
  case char
232
- when "<", "[", "("
233
- depth += 1
326
+ when "("
327
+ paren_depth += 1
234
328
  current += char
235
- when ">", "]", ")"
236
- depth -= 1
329
+ when ")"
330
+ paren_depth -= 1
331
+ current += char
332
+ when "["
333
+ bracket_depth += 1
334
+ current += char
335
+ when "]"
336
+ bracket_depth -= 1
337
+ current += char
338
+ when "<"
339
+ # Only count as generic if it's not part of operator like <=, <=>
340
+ if next_char != "=" && next_char != ">"
341
+ angle_depth += 1
342
+ end
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
347
+ if angle_depth.positive? && prev_char != "-"
348
+ angle_depth -= 1
349
+ end
350
+ current += char
351
+ when "{"
352
+ brace_depth += 1
353
+ current += char
354
+ when "}"
355
+ brace_depth -= 1
237
356
  current += char
238
357
  when ","
239
- if depth.zero?
358
+ if paren_depth.zero? && bracket_depth.zero? && angle_depth.zero? && brace_depth.zero?
240
359
  result << current.strip
241
360
  current = ""
242
361
  else
@@ -245,6 +364,7 @@ module TRuby
245
364
  else
246
365
  current += char
247
366
  end
367
+ i += 1
248
368
  end
249
369
 
250
370
  result << current.strip unless current.empty?
@@ -263,7 +383,7 @@ module TRuby
263
383
  return_type = match[2]&.strip
264
384
 
265
385
  # Check return type if it's a simple type name
266
- if return_type&.match?(/^\w+$/) && !(VALID_TYPES.include?(return_type) || @type_aliases.key?(return_type))
386
+ if return_type&.match?(/^\w+$/) && !(VALID_TYPES.include?(return_type) || @type_aliases.key?(return_type) || @interfaces.key?(return_type))
267
387
  @errors << "Line #{idx + 1}: Unknown return type '#{return_type}'"
268
388
  end
269
389
 
@@ -286,24 +406,70 @@ module TRuby
286
406
 
287
407
  # Only check simple type names against VALID_TYPES
288
408
  next unless param_type.match?(/^\w+$/)
289
- next if VALID_TYPES.include?(param_type) || @type_aliases.key?(param_type)
409
+ next if VALID_TYPES.include?(param_type) || @type_aliases.key?(param_type) || @interfaces.key?(param_type)
290
410
 
291
411
  @errors << "Line #{line_idx + 1}: Unknown parameter type '#{param_type}'"
292
412
  end
293
413
  end
294
414
 
295
415
  def check_duplicate_definitions
416
+ current_class = nil
417
+ class_methods = {} # { class_name => { method_name => line_number } }
418
+
296
419
  @lines.each_with_index do |line, idx|
297
- next unless line.match?(/^\s*def\s+(\w+)/)
420
+ # Track class context
421
+ if line.match?(/^\s*class\s+(\w+)/)
422
+ current_class = line.match(/class\s+(\w+)/)[1]
423
+ class_methods[current_class] ||= {}
424
+ elsif line.match?(/^\s*end\s*$/) && current_class
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*$/)
428
+ end
429
+
430
+ # Use unicode-aware pattern for function names (supports Korean, etc.)
431
+ next unless line.match?(/^\s*def\s+#{IDENTIFIER_PATTERN}/)
298
432
 
299
- func_name = line.match(/def\s+(\w+)/)[1]
433
+ func_name = line.match(/def\s+(#{IDENTIFIER_PATTERN})/)[1]
300
434
 
301
- if @functions[func_name]
435
+ if current_class
436
+ # Method inside a class - check within class scope
437
+ methods = class_methods[current_class]
438
+ if methods[func_name]
439
+ @errors << "Line #{idx + 1}: Function '#{func_name}' is already defined at line #{methods[func_name]}"
440
+ else
441
+ methods[func_name] = idx + 1
442
+ end
443
+ elsif @functions[func_name]
444
+ # Top-level function - check global scope
302
445
  @errors << "Line #{idx + 1}: Function '#{func_name}' is already defined at line #{@functions[func_name]}"
303
446
  else
304
447
  @functions[func_name] = idx + 1
305
448
  end
306
449
  end
307
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)
455
+ balance = 0
456
+ i = 0
457
+ while i < type_str.length
458
+ char = type_str[i]
459
+ prev_char = i.positive? ? type_str[i - 1] : nil
460
+ next_char = type_str[i + 1]
461
+
462
+ case char
463
+ when "<"
464
+ # Skip if it's part of <= or <>
465
+ balance += 1 unless %w[= >].include?(next_char)
466
+ when ">"
467
+ # Skip if it's part of -> arrow operator
468
+ balance -= 1 unless prev_char == "-"
469
+ end
470
+ i += 1
471
+ end
472
+ balance
473
+ end
308
474
  end
309
475
  end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TRuby
4
+ class ErrorReporter
5
+ attr_reader :diagnostics
6
+
7
+ def initialize(formatter: nil)
8
+ @diagnostics = []
9
+ @formatter = formatter || DiagnosticFormatter.new
10
+ @source_cache = {}
11
+ end
12
+
13
+ def add(diagnostic)
14
+ @diagnostics << diagnostic
15
+ end
16
+
17
+ def add_type_check_error(error, file:, source: nil)
18
+ source ||= load_source(file)
19
+ add(Diagnostic.from_type_check_error(error, file: file, source: source))
20
+ end
21
+
22
+ def add_parse_error(error, file:, source: nil)
23
+ source ||= load_source(file)
24
+ add(Diagnostic.from_parse_error(error, file: file, source: source))
25
+ end
26
+
27
+ def add_scan_error(error, file:, source: nil)
28
+ source ||= load_source(file)
29
+ add(Diagnostic.from_scan_error(error, file: file, source: source))
30
+ end
31
+
32
+ def has_errors?
33
+ @diagnostics.any? { |d| d.severity == Diagnostic::SEVERITY_ERROR }
34
+ end
35
+
36
+ def error_count
37
+ @diagnostics.count { |d| d.severity == Diagnostic::SEVERITY_ERROR }
38
+ end
39
+
40
+ def report
41
+ @formatter.format_all(@diagnostics)
42
+ end
43
+
44
+ def clear
45
+ @diagnostics.clear
46
+ @source_cache.clear
47
+ end
48
+
49
+ private
50
+
51
+ def load_source(file)
52
+ return nil unless file && File.exist?(file)
53
+
54
+ @source_cache[file] ||= File.read(file)
55
+ end
56
+ end
57
+ end