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.
- checksums.yaml +4 -4
- data/lib/t_ruby/ast_type_inferrer.rb +2 -0
- data/lib/t_ruby/cache.rb +40 -10
- data/lib/t_ruby/cli.rb +14 -9
- data/lib/t_ruby/code_emitter.rb +254 -0
- data/lib/t_ruby/compiler.rb +186 -3
- data/lib/t_ruby/config.rb +18 -3
- data/lib/t_ruby/diagnostic.rb +115 -0
- data/lib/t_ruby/diagnostic_formatter.rb +162 -0
- data/lib/t_ruby/error_handler.rb +201 -35
- data/lib/t_ruby/error_reporter.rb +57 -0
- data/lib/t_ruby/ir.rb +39 -1
- data/lib/t_ruby/lsp_server.rb +40 -97
- data/lib/t_ruby/parser.rb +18 -4
- data/lib/t_ruby/parser_combinator/combinators/alternative.rb +20 -0
- data/lib/t_ruby/parser_combinator/combinators/chain_left.rb +34 -0
- data/lib/t_ruby/parser_combinator/combinators/choice.rb +29 -0
- data/lib/t_ruby/parser_combinator/combinators/flat_map.rb +21 -0
- data/lib/t_ruby/parser_combinator/combinators/label.rb +22 -0
- data/lib/t_ruby/parser_combinator/combinators/lookahead.rb +21 -0
- data/lib/t_ruby/parser_combinator/combinators/many.rb +29 -0
- data/lib/t_ruby/parser_combinator/combinators/many1.rb +32 -0
- data/lib/t_ruby/parser_combinator/combinators/map.rb +17 -0
- data/lib/t_ruby/parser_combinator/combinators/not_followed_by.rb +21 -0
- data/lib/t_ruby/parser_combinator/combinators/optional.rb +21 -0
- data/lib/t_ruby/parser_combinator/combinators/sep_by.rb +34 -0
- data/lib/t_ruby/parser_combinator/combinators/sep_by1.rb +34 -0
- data/lib/t_ruby/parser_combinator/combinators/sequence.rb +23 -0
- data/lib/t_ruby/parser_combinator/combinators/skip_right.rb +23 -0
- data/lib/t_ruby/parser_combinator/declaration_parser.rb +147 -0
- data/lib/t_ruby/parser_combinator/dsl.rb +115 -0
- data/lib/t_ruby/parser_combinator/parse_error.rb +48 -0
- data/lib/t_ruby/parser_combinator/parse_result.rb +46 -0
- data/lib/t_ruby/parser_combinator/parser.rb +84 -0
- data/lib/t_ruby/parser_combinator/primitives/end_of_input.rb +16 -0
- data/lib/t_ruby/parser_combinator/primitives/fail.rb +16 -0
- data/lib/t_ruby/parser_combinator/primitives/lazy.rb +18 -0
- data/lib/t_ruby/parser_combinator/primitives/literal.rb +21 -0
- data/lib/t_ruby/parser_combinator/primitives/pure.rb +16 -0
- data/lib/t_ruby/parser_combinator/primitives/regex.rb +25 -0
- data/lib/t_ruby/parser_combinator/primitives/satisfy.rb +21 -0
- data/lib/t_ruby/parser_combinator/token/expression_parser.rb +541 -0
- data/lib/t_ruby/parser_combinator/token/statement_parser.rb +644 -0
- data/lib/t_ruby/parser_combinator/token/token_alternative.rb +20 -0
- data/lib/t_ruby/parser_combinator/token/token_body_parser.rb +54 -0
- data/lib/t_ruby/parser_combinator/token/token_declaration_parser.rb +920 -0
- data/lib/t_ruby/parser_combinator/token/token_dsl.rb +16 -0
- data/lib/t_ruby/parser_combinator/token/token_label.rb +22 -0
- data/lib/t_ruby/parser_combinator/token/token_many.rb +29 -0
- data/lib/t_ruby/parser_combinator/token/token_many1.rb +32 -0
- data/lib/t_ruby/parser_combinator/token/token_map.rb +17 -0
- data/lib/t_ruby/parser_combinator/token/token_matcher.rb +29 -0
- data/lib/t_ruby/parser_combinator/token/token_optional.rb +21 -0
- data/lib/t_ruby/parser_combinator/token/token_parse_result.rb +40 -0
- data/lib/t_ruby/parser_combinator/token/token_parser.rb +62 -0
- data/lib/t_ruby/parser_combinator/token/token_sep_by.rb +34 -0
- data/lib/t_ruby/parser_combinator/token/token_sep_by1.rb +34 -0
- data/lib/t_ruby/parser_combinator/token/token_sequence.rb +23 -0
- data/lib/t_ruby/parser_combinator/token/token_skip_right.rb +23 -0
- data/lib/t_ruby/parser_combinator/type_parser.rb +103 -0
- data/lib/t_ruby/parser_combinator.rb +64 -936
- data/lib/t_ruby/ruby_version.rb +112 -0
- data/lib/t_ruby/scanner.rb +883 -0
- data/lib/t_ruby/version.rb +1 -1
- data/lib/t_ruby/watcher.rb +83 -76
- data/lib/t_ruby.rb +17 -1
- metadata +58 -7
- 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
|
data/lib/t_ruby/error_handler.rb
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
275
|
+
type_and_default = match[2].strip
|
|
215
276
|
|
|
216
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
326
|
+
when "("
|
|
327
|
+
paren_depth += 1
|
|
234
328
|
current += char
|
|
235
|
-
when "
|
|
236
|
-
|
|
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
|
|
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
|
-
|
|
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+(
|
|
433
|
+
func_name = line.match(/def\s+(#{IDENTIFIER_PATTERN})/)[1]
|
|
300
434
|
|
|
301
|
-
if
|
|
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
|