t-ruby 0.0.42 → 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 +13 -8
- data/lib/t_ruby/compiler.rb +168 -0
- 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/scanner.rb +883 -0
- data/lib/t_ruby/version.rb +1 -1
- data/lib/t_ruby/watcher.rb +67 -75
- data/lib/t_ruby.rb +15 -1
- metadata +51 -2
- data/lib/t_ruby/body_parser.rb +0 -561
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb406ad473738afc16343a01b9f7e8e0f77a8b0fe700dd3ced2df65197bce626
|
|
4
|
+
data.tar.gz: 95f1ce4ede5378e17393046c223c46b404d95e6506235e210aeba20a9615f8ac
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6003aa038a7b476b8682aed9e32ba19ddbee6c124fe294c79b0371924527002f20c3ccdf45f71e2ee7360bef4e126d1ee0c15d93afd033df1166c20b5719187a
|
|
7
|
+
data.tar.gz: c0c462fada5b5d98f46298dc307d6000b56d537b8d7f80af1faba2206c682cebf02e588277ca5ea2bf6f4e21141928540c733f0f8851adc48b4ca1be8ede6f81
|
data/lib/t_ruby/cache.rb
CHANGED
|
@@ -318,7 +318,7 @@ module TRuby
|
|
|
318
318
|
def needs_compile?(file_path)
|
|
319
319
|
return true unless File.exist?(file_path)
|
|
320
320
|
|
|
321
|
-
current_hash =
|
|
321
|
+
current_hash = compute_file_hash(file_path)
|
|
322
322
|
stored_hash = @file_hashes[file_path]
|
|
323
323
|
|
|
324
324
|
return true if stored_hash.nil? || stored_hash != current_hash
|
|
@@ -333,7 +333,7 @@ module TRuby
|
|
|
333
333
|
return @compiled_files[file_path] unless needs_compile?(file_path)
|
|
334
334
|
|
|
335
335
|
result = @compiler.compile(file_path)
|
|
336
|
-
@file_hashes[file_path] =
|
|
336
|
+
@file_hashes[file_path] = compute_file_hash(file_path)
|
|
337
337
|
@compiled_files[file_path] = result
|
|
338
338
|
|
|
339
339
|
result
|
|
@@ -365,9 +365,14 @@ module TRuby
|
|
|
365
365
|
@cache.stats # Just accessing for potential cleanup
|
|
366
366
|
end
|
|
367
367
|
|
|
368
|
+
# Update file hash after external compile (for watcher integration)
|
|
369
|
+
def update_file_hash(file_path)
|
|
370
|
+
@file_hashes[file_path] = compute_file_hash(file_path)
|
|
371
|
+
end
|
|
372
|
+
|
|
368
373
|
private
|
|
369
374
|
|
|
370
|
-
def
|
|
375
|
+
def compute_file_hash(file_path)
|
|
371
376
|
return nil unless File.exist?(file_path)
|
|
372
377
|
|
|
373
378
|
Digest::SHA256.hexdigest(File.read(file_path))
|
|
@@ -683,27 +688,52 @@ module TRuby
|
|
|
683
688
|
end
|
|
684
689
|
|
|
685
690
|
# Compile all with cross-file checking
|
|
691
|
+
# Returns diagnostics using unified Diagnostic format
|
|
686
692
|
def compile_all_with_checking(file_paths)
|
|
687
693
|
results = {}
|
|
688
|
-
|
|
694
|
+
all_diagnostics = []
|
|
689
695
|
|
|
690
696
|
# First pass: compile and register all files
|
|
691
697
|
file_paths.each do |file_path|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
698
|
+
source = File.exist?(file_path) ? File.read(file_path) : nil
|
|
699
|
+
|
|
700
|
+
begin
|
|
701
|
+
results[file_path] = compile_with_ir(file_path)
|
|
702
|
+
rescue TypeCheckError => e
|
|
703
|
+
all_diagnostics << Diagnostic.from_type_check_error(e, file: file_path, source: source)
|
|
704
|
+
rescue ParseError => e
|
|
705
|
+
all_diagnostics << Diagnostic.from_parse_error(e, file: file_path, source: source)
|
|
706
|
+
rescue Scanner::ScanError => e
|
|
707
|
+
all_diagnostics << Diagnostic.from_scan_error(e, file: file_path, source: source)
|
|
708
|
+
rescue StandardError => e
|
|
709
|
+
all_diagnostics << Diagnostic.new(
|
|
710
|
+
code: "TR0001",
|
|
711
|
+
message: e.message,
|
|
712
|
+
file: file_path,
|
|
713
|
+
line: 1,
|
|
714
|
+
column: 1
|
|
715
|
+
)
|
|
716
|
+
end
|
|
695
717
|
end
|
|
696
718
|
|
|
697
719
|
# Second pass: cross-file type checking
|
|
698
720
|
if @cross_file_checker
|
|
699
721
|
check_result = @cross_file_checker.check_all
|
|
700
|
-
|
|
722
|
+
check_result[:errors].each do |e|
|
|
723
|
+
all_diagnostics << Diagnostic.new(
|
|
724
|
+
code: "TR2002",
|
|
725
|
+
message: e[:message],
|
|
726
|
+
file: e[:file],
|
|
727
|
+
line: 1,
|
|
728
|
+
column: 1
|
|
729
|
+
)
|
|
730
|
+
end
|
|
701
731
|
end
|
|
702
732
|
|
|
703
733
|
{
|
|
704
734
|
results: results,
|
|
705
|
-
|
|
706
|
-
success:
|
|
735
|
+
diagnostics: all_diagnostics,
|
|
736
|
+
success: all_diagnostics.empty?,
|
|
707
737
|
}
|
|
708
738
|
end
|
|
709
739
|
|
data/lib/t_ruby/cli.rb
CHANGED
|
@@ -214,14 +214,19 @@ module TRuby
|
|
|
214
214
|
config = Config.new(config_path)
|
|
215
215
|
compiler = Compiler.new(config)
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
217
|
+
result = compiler.compile_with_diagnostics(input_file)
|
|
218
|
+
|
|
219
|
+
if result[:success]
|
|
220
|
+
puts "Compiled: #{input_file} -> #{result[:output_path]}"
|
|
221
|
+
else
|
|
222
|
+
formatter = DiagnosticFormatter.new(use_colors: $stdout.tty?)
|
|
223
|
+
result[:diagnostics].each do |diagnostic|
|
|
224
|
+
puts formatter.format(diagnostic)
|
|
225
|
+
end
|
|
226
|
+
puts
|
|
227
|
+
puts formatter.send(:format_summary, result[:diagnostics])
|
|
228
|
+
exit 1
|
|
229
|
+
end
|
|
225
230
|
end
|
|
226
231
|
|
|
227
232
|
# Extract config path from --config or -c flag
|
data/lib/t_ruby/compiler.rb
CHANGED
|
@@ -76,6 +76,161 @@ module TRuby
|
|
|
76
76
|
output_path
|
|
77
77
|
end
|
|
78
78
|
|
|
79
|
+
# Compile a file and return result with diagnostics
|
|
80
|
+
# This is the unified compilation interface for CLI and Watcher
|
|
81
|
+
# @param input_path [String] Path to the input file
|
|
82
|
+
# @return [Hash] Result with :success, :output_path, :diagnostics keys
|
|
83
|
+
def compile_with_diagnostics(input_path)
|
|
84
|
+
source = File.exist?(input_path) ? File.read(input_path) : nil
|
|
85
|
+
all_diagnostics = []
|
|
86
|
+
|
|
87
|
+
# Run analyze first to get all diagnostics (colon spacing, etc.)
|
|
88
|
+
if source
|
|
89
|
+
all_diagnostics = analyze(source, file: input_path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
begin
|
|
93
|
+
output_path = compile(input_path)
|
|
94
|
+
# Compilation succeeded, but we may still have diagnostics from analyze
|
|
95
|
+
{
|
|
96
|
+
success: all_diagnostics.empty?,
|
|
97
|
+
output_path: all_diagnostics.empty? ? output_path : nil,
|
|
98
|
+
diagnostics: all_diagnostics,
|
|
99
|
+
}
|
|
100
|
+
rescue TypeCheckError => e
|
|
101
|
+
# Skip if already reported by analyze (same message and location)
|
|
102
|
+
new_diag = Diagnostic.from_type_check_error(e, file: input_path, source: source)
|
|
103
|
+
unless all_diagnostics.any? { |d| d.message == new_diag.message && d.line == new_diag.line }
|
|
104
|
+
all_diagnostics << new_diag
|
|
105
|
+
end
|
|
106
|
+
{
|
|
107
|
+
success: false,
|
|
108
|
+
output_path: nil,
|
|
109
|
+
diagnostics: all_diagnostics,
|
|
110
|
+
}
|
|
111
|
+
rescue ParseError => e
|
|
112
|
+
new_diag = Diagnostic.from_parse_error(e, file: input_path, source: source)
|
|
113
|
+
unless all_diagnostics.any? { |d| d.message == new_diag.message && d.line == new_diag.line }
|
|
114
|
+
all_diagnostics << new_diag
|
|
115
|
+
end
|
|
116
|
+
{
|
|
117
|
+
success: false,
|
|
118
|
+
output_path: nil,
|
|
119
|
+
diagnostics: all_diagnostics,
|
|
120
|
+
}
|
|
121
|
+
rescue Scanner::ScanError => e
|
|
122
|
+
new_diag = Diagnostic.from_scan_error(e, file: input_path, source: source)
|
|
123
|
+
unless all_diagnostics.any? { |d| d.message == new_diag.message && d.line == new_diag.line }
|
|
124
|
+
all_diagnostics << new_diag
|
|
125
|
+
end
|
|
126
|
+
{
|
|
127
|
+
success: false,
|
|
128
|
+
output_path: nil,
|
|
129
|
+
diagnostics: all_diagnostics,
|
|
130
|
+
}
|
|
131
|
+
rescue ArgumentError => e
|
|
132
|
+
all_diagnostics << Diagnostic.new(
|
|
133
|
+
code: "TR0001",
|
|
134
|
+
message: e.message,
|
|
135
|
+
file: input_path,
|
|
136
|
+
severity: Diagnostic::SEVERITY_ERROR
|
|
137
|
+
)
|
|
138
|
+
{
|
|
139
|
+
success: false,
|
|
140
|
+
output_path: nil,
|
|
141
|
+
diagnostics: all_diagnostics,
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Analyze source code without compiling - returns diagnostics only
|
|
147
|
+
# This is the unified analysis interface for LSP and other tools
|
|
148
|
+
# @param source [String] T-Ruby source code
|
|
149
|
+
# @param file [String] File path for error reporting (optional)
|
|
150
|
+
# @return [Array<Diagnostic>] Array of diagnostic objects
|
|
151
|
+
def analyze(source, file: "<source>")
|
|
152
|
+
diagnostics = []
|
|
153
|
+
source_lines = source.split("\n")
|
|
154
|
+
|
|
155
|
+
# Run ErrorHandler checks (syntax validation, duplicate definitions, etc.)
|
|
156
|
+
error_handler = ErrorHandler.new(source)
|
|
157
|
+
errors = error_handler.check
|
|
158
|
+
errors.each do |error|
|
|
159
|
+
# Parse line number from "Line N: message" format
|
|
160
|
+
next unless error =~ /^Line (\d+):\s*(.+)$/
|
|
161
|
+
|
|
162
|
+
line_num = Regexp.last_match(1).to_i
|
|
163
|
+
message = Regexp.last_match(2)
|
|
164
|
+
source_line = source_lines[line_num - 1] if line_num.positive?
|
|
165
|
+
diagnostics << Diagnostic.new(
|
|
166
|
+
code: "TR1002",
|
|
167
|
+
message: message,
|
|
168
|
+
file: file,
|
|
169
|
+
line: line_num,
|
|
170
|
+
column: 1,
|
|
171
|
+
source_line: source_line,
|
|
172
|
+
severity: Diagnostic::SEVERITY_ERROR
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Run TokenDeclarationParser for colon spacing and declaration syntax validation
|
|
177
|
+
begin
|
|
178
|
+
scanner = Scanner.new(source)
|
|
179
|
+
tokens = scanner.scan_all
|
|
180
|
+
decl_parser = ParserCombinator::TokenDeclarationParser.new
|
|
181
|
+
decl_parser.parse_program(tokens)
|
|
182
|
+
|
|
183
|
+
if decl_parser.has_errors?
|
|
184
|
+
decl_parser.errors.each do |err|
|
|
185
|
+
source_line = source_lines[err.line - 1] if err.line.positive? && err.line <= source_lines.length
|
|
186
|
+
diagnostics << Diagnostic.new(
|
|
187
|
+
code: "TR1003",
|
|
188
|
+
message: err.message,
|
|
189
|
+
file: file,
|
|
190
|
+
line: err.line,
|
|
191
|
+
column: err.column,
|
|
192
|
+
source_line: source_line,
|
|
193
|
+
severity: Diagnostic::SEVERITY_ERROR
|
|
194
|
+
)
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
rescue Scanner::ScanError
|
|
198
|
+
# Scanner errors will be caught below in the main parse section
|
|
199
|
+
rescue StandardError
|
|
200
|
+
# Ignore TokenDeclarationParser errors for now - regex parser is authoritative
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
begin
|
|
204
|
+
# Parse source with regex-based parser for IR generation
|
|
205
|
+
parser = Parser.new(source)
|
|
206
|
+
parser.parse
|
|
207
|
+
|
|
208
|
+
# Run type checking if enabled and IR is available
|
|
209
|
+
if type_check? && parser.ir_program
|
|
210
|
+
begin
|
|
211
|
+
check_types(parser.ir_program, file)
|
|
212
|
+
rescue TypeCheckError => e
|
|
213
|
+
diagnostics << Diagnostic.from_type_check_error(e, file: file, source: source)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
rescue ParseError => e
|
|
217
|
+
diagnostics << Diagnostic.from_parse_error(e, file: file, source: source)
|
|
218
|
+
rescue Scanner::ScanError => e
|
|
219
|
+
diagnostics << Diagnostic.from_scan_error(e, file: file, source: source)
|
|
220
|
+
rescue StandardError => e
|
|
221
|
+
diagnostics << Diagnostic.new(
|
|
222
|
+
code: "TR0001",
|
|
223
|
+
message: e.message,
|
|
224
|
+
file: file,
|
|
225
|
+
line: 1,
|
|
226
|
+
column: 1,
|
|
227
|
+
severity: Diagnostic::SEVERITY_ERROR
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
diagnostics
|
|
232
|
+
end
|
|
233
|
+
|
|
79
234
|
# Compile T-Ruby source code from a string (useful for WASM/playground)
|
|
80
235
|
# @param source [String] T-Ruby source code
|
|
81
236
|
# @param options [Hash] Options for compilation
|
|
@@ -319,6 +474,19 @@ module TRuby
|
|
|
319
474
|
# Subtype relationships
|
|
320
475
|
return true if subtype_of?(inferred, declared)
|
|
321
476
|
|
|
477
|
+
# Handle generic types (e.g., Array[untyped] is compatible with Array[String])
|
|
478
|
+
if inferred.include?("[") && declared.include?("[")
|
|
479
|
+
inferred_base = inferred.split("[").first
|
|
480
|
+
declared_base = declared.split("[").first
|
|
481
|
+
if inferred_base == declared_base
|
|
482
|
+
# Extract type arguments
|
|
483
|
+
inferred_args = inferred[/\[(.+)\]/, 1]
|
|
484
|
+
declared_args = declared[/\[(.+)\]/, 1]
|
|
485
|
+
# untyped type argument is compatible with any type argument
|
|
486
|
+
return true if inferred_args == "untyped" || declared_args == "untyped"
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
|
|
322
490
|
# Handle union types in declared
|
|
323
491
|
if declared.include?("|")
|
|
324
492
|
declared_types = declared.split("|").map(&:strip)
|
|
@@ -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
|