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.
Files changed (65) 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 +13 -8
  5. data/lib/t_ruby/compiler.rb +168 -0
  6. data/lib/t_ruby/diagnostic.rb +115 -0
  7. data/lib/t_ruby/diagnostic_formatter.rb +162 -0
  8. data/lib/t_ruby/error_handler.rb +201 -35
  9. data/lib/t_ruby/error_reporter.rb +57 -0
  10. data/lib/t_ruby/ir.rb +39 -1
  11. data/lib/t_ruby/lsp_server.rb +40 -97
  12. data/lib/t_ruby/parser.rb +18 -4
  13. data/lib/t_ruby/parser_combinator/combinators/alternative.rb +20 -0
  14. data/lib/t_ruby/parser_combinator/combinators/chain_left.rb +34 -0
  15. data/lib/t_ruby/parser_combinator/combinators/choice.rb +29 -0
  16. data/lib/t_ruby/parser_combinator/combinators/flat_map.rb +21 -0
  17. data/lib/t_ruby/parser_combinator/combinators/label.rb +22 -0
  18. data/lib/t_ruby/parser_combinator/combinators/lookahead.rb +21 -0
  19. data/lib/t_ruby/parser_combinator/combinators/many.rb +29 -0
  20. data/lib/t_ruby/parser_combinator/combinators/many1.rb +32 -0
  21. data/lib/t_ruby/parser_combinator/combinators/map.rb +17 -0
  22. data/lib/t_ruby/parser_combinator/combinators/not_followed_by.rb +21 -0
  23. data/lib/t_ruby/parser_combinator/combinators/optional.rb +21 -0
  24. data/lib/t_ruby/parser_combinator/combinators/sep_by.rb +34 -0
  25. data/lib/t_ruby/parser_combinator/combinators/sep_by1.rb +34 -0
  26. data/lib/t_ruby/parser_combinator/combinators/sequence.rb +23 -0
  27. data/lib/t_ruby/parser_combinator/combinators/skip_right.rb +23 -0
  28. data/lib/t_ruby/parser_combinator/declaration_parser.rb +147 -0
  29. data/lib/t_ruby/parser_combinator/dsl.rb +115 -0
  30. data/lib/t_ruby/parser_combinator/parse_error.rb +48 -0
  31. data/lib/t_ruby/parser_combinator/parse_result.rb +46 -0
  32. data/lib/t_ruby/parser_combinator/parser.rb +84 -0
  33. data/lib/t_ruby/parser_combinator/primitives/end_of_input.rb +16 -0
  34. data/lib/t_ruby/parser_combinator/primitives/fail.rb +16 -0
  35. data/lib/t_ruby/parser_combinator/primitives/lazy.rb +18 -0
  36. data/lib/t_ruby/parser_combinator/primitives/literal.rb +21 -0
  37. data/lib/t_ruby/parser_combinator/primitives/pure.rb +16 -0
  38. data/lib/t_ruby/parser_combinator/primitives/regex.rb +25 -0
  39. data/lib/t_ruby/parser_combinator/primitives/satisfy.rb +21 -0
  40. data/lib/t_ruby/parser_combinator/token/expression_parser.rb +541 -0
  41. data/lib/t_ruby/parser_combinator/token/statement_parser.rb +644 -0
  42. data/lib/t_ruby/parser_combinator/token/token_alternative.rb +20 -0
  43. data/lib/t_ruby/parser_combinator/token/token_body_parser.rb +54 -0
  44. data/lib/t_ruby/parser_combinator/token/token_declaration_parser.rb +920 -0
  45. data/lib/t_ruby/parser_combinator/token/token_dsl.rb +16 -0
  46. data/lib/t_ruby/parser_combinator/token/token_label.rb +22 -0
  47. data/lib/t_ruby/parser_combinator/token/token_many.rb +29 -0
  48. data/lib/t_ruby/parser_combinator/token/token_many1.rb +32 -0
  49. data/lib/t_ruby/parser_combinator/token/token_map.rb +17 -0
  50. data/lib/t_ruby/parser_combinator/token/token_matcher.rb +29 -0
  51. data/lib/t_ruby/parser_combinator/token/token_optional.rb +21 -0
  52. data/lib/t_ruby/parser_combinator/token/token_parse_result.rb +40 -0
  53. data/lib/t_ruby/parser_combinator/token/token_parser.rb +62 -0
  54. data/lib/t_ruby/parser_combinator/token/token_sep_by.rb +34 -0
  55. data/lib/t_ruby/parser_combinator/token/token_sep_by1.rb +34 -0
  56. data/lib/t_ruby/parser_combinator/token/token_sequence.rb +23 -0
  57. data/lib/t_ruby/parser_combinator/token/token_skip_right.rb +23 -0
  58. data/lib/t_ruby/parser_combinator/type_parser.rb +103 -0
  59. data/lib/t_ruby/parser_combinator.rb +64 -936
  60. data/lib/t_ruby/scanner.rb +883 -0
  61. data/lib/t_ruby/version.rb +1 -1
  62. data/lib/t_ruby/watcher.rb +67 -75
  63. data/lib/t_ruby.rb +15 -1
  64. metadata +51 -2
  65. data/lib/t_ruby/body_parser.rb +0 -561
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 75019794a577853cf5a7e704febef3868a1ddd06776e43f50dc684d7dde87e3c
4
- data.tar.gz: 89a255f7e7b68e2becd6314be09470424d5f418324c40bcc77a7311e6006d9f0
3
+ metadata.gz: cb406ad473738afc16343a01b9f7e8e0f77a8b0fe700dd3ced2df65197bce626
4
+ data.tar.gz: 95f1ce4ede5378e17393046c223c46b404d95e6506235e210aeba20a9615f8ac
5
5
  SHA512:
6
- metadata.gz: 4000a38df6e83591a15f453af3b4d5f759b1af8e869478737c1d7711e34c7e1d65c89df34e56a6575d41e2f120e951970fde1ac444bf24d73c1167a24de875c4
7
- data.tar.gz: 9273b4bf7d8a9af3ccbe0fa0d93914b13c9ce09f464902a4974749780871516c8fbaeb33a60d1dca754914dde09e498148ec080ff31fef3b11a0a88d96642eee
6
+ metadata.gz: 6003aa038a7b476b8682aed9e32ba19ddbee6c124fe294c79b0371924527002f20c3ccdf45f71e2ee7360bef4e126d1ee0c15d93afd033df1166c20b5719187a
7
+ data.tar.gz: c0c462fada5b5d98f46298dc307d6000b56d537b8d7f80af1faba2206c682cebf02e588277ca5ea2bf6f4e21141928540c733f0f8851adc48b4ca1be8ede6f81
@@ -158,6 +158,8 @@ module TRuby
158
158
  type = case node
159
159
  when IR::Literal
160
160
  infer_literal(node)
161
+ when IR::InterpolatedString
162
+ "String" # Interpolated strings always produce String
161
163
  when IR::VariableRef
162
164
  infer_variable_ref(node, env)
163
165
  when IR::BinaryOp
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 = file_hash(file_path)
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] = file_hash(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 file_hash(file_path)
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
- errors = []
694
+ all_diagnostics = []
689
695
 
690
696
  # First pass: compile and register all files
691
697
  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
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
- 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
701
731
  end
702
732
 
703
733
  {
704
734
  results: results,
705
- errors: errors,
706
- success: errors.empty?,
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
- 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
225
230
  end
226
231
 
227
232
  # Extract config path from --config or -c flag
@@ -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