Skip to content

Commit 7ad4cf4

Browse files
authored
feat: improve error messages with tsc-style diagnostics (#30)
* feat: add Diagnostic class for unified error structure - Add Diagnostic class with code, message, file, line, column attributes - Add factory methods: from_type_check_error, from_parse_error, from_scan_error - Add comprehensive tests for all functionality - TR1xxx codes for parser errors, TR2xxx for type errors * feat: add DiagnosticFormatter with tsc-style output - Format errors as file:line:col - severity CODE: message - Display source code snippets with line numbers - Show error markers (~~~) under problem location - Include Expected/Actual/Suggestion context - Support ANSI colors with TTY auto-detection - Format summary line: Found X errors and Y warnings * feat: add ErrorReporter for collecting and reporting errors - Collect multiple diagnostics during compilation - Convert TypeCheckError, ParseError, ScanError to Diagnostic - Auto-load source from file when not provided - Report formatted output using DiagnosticFormatter - Track error vs warning counts * feat: integrate ErrorReporter into CLI - Use ErrorReporter for TypeCheckError, ParseError, ScanError - Display tsc-style formatted error output - Include source code snippets and error markers - Show Expected/Actual/Suggestion context - Display summary line with error count * refactor: use DiagnosticFormatter in Watcher - Replace hash-based error format with Diagnostic objects - Use DiagnosticFormatter for consistent tsc-style output - Include source code snippets in watch mode errors - Update tests to expect Diagnostic objects * feat: add location info to MethodDef for better error messages - TokenDeclarationParser: capture def token's line/column - Parser.parse_function_with_body: add line/column to func_info - Parser.parse_method_in_class: add line/column to method_info - IR CodeGenerator.build_method: pass location to MethodDef Error messages now show exact line:column position: src/file.trb:18:1 - error TR2001: Type mismatch... * fix: improve watch mode incremental diagnostics caching - Add @file_diagnostics cache to preserve errors across incremental compiles - Use compile_with_diagnostics consistently in compile_file_with_ir - Add update_file_hash method to EnhancedIncrementalCompiler - Rename file_hash to compute_file_hash (private method) - Fix error count calculation from cached diagnostics - Fix various RuboCop offenses (guard clauses, multiple comparison) This ensures that when saving a file in watch mode, errors from other files are preserved and the total error count remains accurate. * test: add coverage tests for new diagnostic features - Add error_handler tests for Float type, unicode identifiers, class scoping - Add colon_spacing tests for hash literal types and keyword arguments - Add compiler tests for compile_with_diagnostics method * test: improve test coverage to 93%+ with comprehensive spec additions - Add method name validation for spaces (TR1003 error) - Add extensive tests for parser_combinator (Lookahead, NotFollowedBy, FlatMap, Pure, Fail, token parsers) - Add comprehensive SMT solver tests (Formula, BoolConst, Variable, Not, And, Or, Implies, Iff, TypeVar, etc.) - Add IR node tests (children methods, TypeNode, LiteralType, HashLiteralType, Visitor) - Add TypeChecker tests (TypeCheckError, TypeHierarchy, FlowContext, validate_type, etc.) - Add new spec files for untested modules: - benchmark_spec.rb, doc_generator_spec.rb - docs_badge_generator_spec.rb, docs_example_extractor_spec.rb, docs_example_verifier_spec.rb - generic_type_parser_spec.rb, intersection_type_parser_spec.rb, union_type_parser_spec.rb - string_utils_spec.rb, type_env_spec.rb, version_checker_spec.rb - Enhance existing specs: ast_type_inferrer, bundler_integration, cache, cli constraint_checker, error_handler, lsp_server, package_manager, type_inferencer, watcher
1 parent a7c451d commit 7ad4cf4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+10546
-261
lines changed

lib/t_ruby.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
require_relative "t_ruby/intersection_type_parser"
2323
require_relative "t_ruby/type_erasure"
2424
require_relative "t_ruby/error_handler"
25+
require_relative "t_ruby/diagnostic"
26+
require_relative "t_ruby/diagnostic_formatter"
27+
require_relative "t_ruby/error_reporter"
2528
require_relative "t_ruby/declaration_generator"
2629
require_relative "t_ruby/compiler"
2730
require_relative "t_ruby/lsp_server"

lib/t_ruby/cache.rb

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ def initialize(compiler, cache: nil)
318318
def needs_compile?(file_path)
319319
return true unless File.exist?(file_path)
320320

321-
current_hash = file_hash(file_path)
321+
current_hash = compute_file_hash(file_path)
322322
stored_hash = @file_hashes[file_path]
323323

324324
return true if stored_hash.nil? || stored_hash != current_hash
@@ -333,7 +333,7 @@ def compile(file_path)
333333
return @compiled_files[file_path] unless needs_compile?(file_path)
334334

335335
result = @compiler.compile(file_path)
336-
@file_hashes[file_path] = file_hash(file_path)
336+
@file_hashes[file_path] = compute_file_hash(file_path)
337337
@compiled_files[file_path] = result
338338

339339
result
@@ -365,9 +365,14 @@ def clear
365365
@cache.stats # Just accessing for potential cleanup
366366
end
367367

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+
368373
private
369374

370-
def file_hash(file_path)
375+
def compute_file_hash(file_path)
371376
return nil unless File.exist?(file_path)
372377

373378
Digest::SHA256.hexdigest(File.read(file_path))
@@ -683,27 +688,52 @@ def compile_with_ir(file_path)
683688
end
684689

685690
# Compile all with cross-file checking
691+
# Returns diagnostics using unified Diagnostic format
686692
def compile_all_with_checking(file_paths)
687693
results = {}
688-
errors = []
694+
all_diagnostics = []
689695

690696
# First pass: compile and register all files
691697
file_paths.each do |file_path|
692-
results[file_path] = compile_with_ir(file_path)
693-
rescue StandardError => e
694-
errors << { file: file_path, error: e.message }
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
695717
end
696718

697719
# Second pass: cross-file type checking
698720
if @cross_file_checker
699721
check_result = @cross_file_checker.check_all
700-
errors.concat(check_result[:errors])
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
701731
end
702732

703733
{
704734
results: results,
705-
errors: errors,
706-
success: errors.empty?,
735+
diagnostics: all_diagnostics,
736+
success: all_diagnostics.empty?,
707737
}
708738
end
709739

lib/t_ruby/cli.rb

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,14 +214,19 @@ def compile(input_file, config_path: nil)
214214
config = Config.new(config_path)
215215
compiler = Compiler.new(config)
216216

217-
output_path = compiler.compile(input_file)
218-
puts "Compiled: #{input_file} -> #{output_path}"
219-
rescue TypeCheckError => e
220-
puts "Type error: #{e.message}"
221-
exit 1
222-
rescue ArgumentError => e
223-
puts "Error: #{e.message}"
224-
exit 1
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
225230
end
226231

227232
# Extract config path from --config or -c flag

lib/t_ruby/compiler.rb

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,161 @@ def compile(input_path)
7676
output_path
7777
end
7878

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+
79234
# Compile T-Ruby source code from a string (useful for WASM/playground)
80235
# @param source [String] T-Ruby source code
81236
# @param options [Hash] Options for compilation

0 commit comments

Comments
 (0)