Class: Herb::Engine::ErrorFormatter

Inherits:
Object
  • Object
show all
Defined in:
lib/herb/engine/error_formatter.rb,
sig/herb/engine/error_formatter.rbs

Constant Summary collapse

CONTEXT_LINES =

Returns:

  • (::Integer)
3

Instance Method Summary collapse

Constructor Details

#initialize(source, errors, options = {}) ⇒ ErrorFormatter

Returns a new instance of ErrorFormatter.

Parameters:

  • source (Object)
  • errors (Object)
  • options (Object) (defaults to: {})


11
12
13
14
15
16
17
18
# File 'lib/herb/engine/error_formatter.rb', line 11

def initialize(source, errors, options = {})
  @source = source
  @errors = errors
  @filename = options[:filename] || "[source]"
  @lines = source.lines
  @use_highlighter = options.fetch(:use_highlighter, true)
  @highlighter_path = options[:highlighter_path] || find_highlighter_path
end

Instance Method Details

#can_use_highlighter?Boolean

Returns:

  • (Boolean)


261
262
263
264
265
266
# File 'lib/herb/engine/error_formatter.rb', line 261

def can_use_highlighter?
  return false unless @highlighter_path
  return false unless File.exist?(@highlighter_path) || system("which #{@highlighter_path} > /dev/null 2>&1")

  system("node --version > /dev/null 2>&1")
end

#find_highlighter_pathObject

Returns:

  • (Object)


252
253
254
255
256
257
258
259
# File 'lib/herb/engine/error_formatter.rb', line 252

def find_highlighter_path
  possible_paths = [
    File.expand_path("../../../javascript/packages/highlighter/bin/herb-highlight", __dir__ || "."),
    "herb-highlight" # In PATH
  ]

  possible_paths.find { |path| File.executable?(path) || system("which #{path} > /dev/null 2>&1") }
end

#format_allObject

Returns:

  • (Object)


20
21
22
23
24
25
26
27
28
# File 'lib/herb/engine/error_formatter.rb', line 20

def format_all
  return "No errors found" if @errors.empty?

  if @use_highlighter && @highlighter_path && can_use_highlighter?
    format_all_with_highlighter
  else
    format_all_without_highlighter
  end
end

#format_all_with_highlighterObject

Returns:

  • (Object)


30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# File 'lib/herb/engine/error_formatter.rb', line 30

def format_all_with_highlighter
  output = String.new
  output << "HTML+ERB Compilation Errors:\n"
  output << ("=" * 60) << "\n\n"

  require "tempfile"
  temp_file = Tempfile.new(["herb_error", ".html.erb"])
  temp_file.write(@source)
  temp_file.close

  begin
    highlighted_output = run_highlighter_with_diagnostics(temp_file.path, CONTEXT_LINES)

    if highlighted_output
      output << highlighted_output
    else
       = @errors.group_by do |error|
        location = error.is_a?(Hash) ? error[:location] : error.location
        location&.start&.line
      end.compact

      .each_with_index do |(line_num, line_errors), group_index|
        output << "Error Group ##{group_index + 1} (Line #{line_num}):\n"
        output << ("-" * 40) << "\n"

        line_errors.each_with_index do |error, index|
          output << format_error_header(error, index + 1)
        end

        output << "\nSource Context:\n"

        highlighted_basic = run_highlighter(temp_file.path, line_num, CONTEXT_LINES)

        output << (highlighted_basic || format_source_context_basic(line_errors.first))

        output << "\n"
        output << format_suggestions(line_errors)
        output << "\n" unless group_index == .length - 1
      end
    end

    output << "\n" << ("=" * 60) << "\n"
    output << "Total errors: #{@errors.length}\n"
    output << "Compilation failed. Please fix the errors above.\n"
  ensure
    temp_file.unlink
  end

  output
end

#format_all_without_highlighterObject

Returns:

  • (Object)


81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# File 'lib/herb/engine/error_formatter.rb', line 81

def format_all_without_highlighter
  output = String.new
  output << "HTML+ERB Compilation Errors:\n"
  output << ("=" * 60) << "\n\n"

  @errors.each_with_index do |error, index|
    output << format_error(error, index + 1)
    output << "\n" unless index == @errors.length - 1
  end

  output << "\n" << ("=" * 60) << "\n"
  output << "Total errors: #{@errors.length}\n"
  output << "Compilation failed. Please fix the errors above.\n"

  output
end

#format_error(error, number) ⇒ Object

Parameters:

  • error (Object)
  • number (Object)

Returns:

  • (Object)


98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
# File 'lib/herb/engine/error_formatter.rb', line 98

def format_error(error, number)
  output = String.new

  error_name = if error.is_a?(Hash)
                 error[:code] || "UnknownError"
               else
                 error.class.name.split("::").last.gsub(/Error$/, "")
               end

  output << "Error ##{number}: #{error_name}\n"
  output << ("-" * 40) << "\n"

  location = error.is_a?(Hash) ? error[:location] : error.location
  if location
    output << "  File: #{@filename}\n"
    output << "  Location: Line #{location.start.line}, Column #{location.start.column}\n"
  end

  error_message = error.is_a?(Hash) ? error[:message] : error.message
  output << "  Message: #{error_message}\n\n"
  output << format_source_context(error) if location
  output << format_error_details(error)

  output
end

#format_error_details(error) ⇒ Object

Parameters:

  • error (Object)

Returns:

  • (Object)


173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
# File 'lib/herb/engine/error_formatter.rb', line 173

def format_error_details(error)
  output = String.new

  case error
  when Herb::Errors::MissingClosingTagError
    if error.opening_tag
      output << "  Opening tag: <#{error.opening_tag.value}> at line #{error.opening_tag.location.start.line}\n"
      output << "  Expected: </#{error.opening_tag.value}>\n"
      output << "  Suggestion: Add the closing tag or use a self-closing tag\n"
    end

  when Herb::Errors::MissingOpeningTagError
    if error.closing_tag
      output << "  Closing tag: </#{error.closing_tag.value}> at line #{error.closing_tag.location.start.line}\n"
      output << "  Suggestion: Add the corresponding opening tag or remove this closing tag\n"
    end

  when Herb::Errors::TagNamesMismatchError
    if error.opening_tag && error.closing_tag
      output << "  Opening tag: <#{error.opening_tag.value}> at line #{error.opening_tag.location.start.line}\n"
      output << "  Closing tag: </#{error.closing_tag.value}> at line #{error.closing_tag.location.start.line}\n"
      output << "  Suggestion: Change the closing tag to </#{error.opening_tag.value}>\n"
    end

  when Herb::Errors::VoidElementClosingTagError
    if error.tag_name
      output << "  Void element: <#{error.tag_name.value}>\n"
      output << "  Note: Void elements like <br>, <img>, <input> cannot have closing tags\n"
      output << "  Suggestion: Remove the closing tag or use <#{error.tag_name.value} />\n"
    end

  when Herb::Errors::UnclosedElementError
    if error.opening_tag
      output << "  Opening tag: <#{error.opening_tag.value}> at line #{error.opening_tag.location.start.line}\n"
      output << "  Note: This element was never closed before the end of the document\n"
      output << "  Suggestion: Add </#{error.opening_tag.value}> before the end of the template\n"
    end

  when Herb::Errors::RubyParseError
    output << "  Ruby error: #{error.diagnostic_id}\n"
    output << "  Level: #{error.level}\n"
    output << "  Details: #{error.error_message}\n"
    output << "  Suggestion: Check your Ruby syntax inside the ERB tag\n"

  when Herb::Errors::MissingAttributeValueError
    output << "  Attribute: #{error.attribute_name}\n"
    output << "  Suggestion: Add a value after the equals sign or remove the equals sign\n"
  end

  output
end

#format_error_header(error, number) ⇒ Object

Parameters:

  • error (Object)
  • number (Object)

Returns:

  • (Object)


358
359
360
361
362
363
364
365
366
367
368
369
370
# File 'lib/herb/engine/error_formatter.rb', line 358

def format_error_header(error, number)
  output = String.new
  output << if error.is_a?(Hash)
              "  #{number}. #{error[:code] || "UnknownError"}: #{error[:message]}\n"
            else
              "  #{number}. #{error.class.name.split("::").last.gsub(/Error$/, "")}: #{error.message}\n"
            end

  location = error.is_a?(Hash) ? error[:location] : error.location
  output << "     Location: Line #{location.start.line}, Column #{location.start.column}\n" if location

  output
end

#format_inline_hint(error) ⇒ Object

Parameters:

  • error (Object)

Returns:

  • (Object)


237
238
239
240
241
242
243
244
245
246
247
248
249
250
# File 'lib/herb/engine/error_formatter.rb', line 237

def format_inline_hint(error)
  case error
  when Herb::Errors::MissingClosingTagError
    "← Missing closing tag"
  when Herb::Errors::TagNamesMismatchError
    "← Tag mismatch"
  when Herb::Errors::UnclosedElementError
    "← Unclosed element"
  when Herb::Errors::MissingAttributeValueError
    "← Missing attribute value"
  else
    ""
  end
end

#format_source_context(error) ⇒ Object

Parameters:

  • error (Object)

Returns:

  • (Object)


126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
# File 'lib/herb/engine/error_formatter.rb', line 126

def format_source_context(error)
  output = String.new
  location = error.is_a?(Hash) ? error[:location] : error.location
  line_num = location.start.line
  col_num = location.start.column

  start_line = [line_num - CONTEXT_LINES, 1].max
  end_line = [line_num + CONTEXT_LINES, @lines.length].min

  output << "  Source:\n"

  (start_line..end_line).each do |i|
    line = @lines[i - 1]
    line_str = line.chomp
    line_prefix = format("  %4d | ", i)

    if i == line_num
      output << "\e[31m"
      output << line_prefix
      output << line_str
      output << "\e[0m\n"

      if col_num.positive?
        pointer = "#{" " * (line_prefix.length + col_num - 1)}^"

        if location.end.column && location.end.column > col_num
          underline_length = location.end.column - col_num
          pointer << ("~" * [underline_length - 1, 0].max)
        end

        output << "\e[31m#{pointer}\e[0m"

        output << " #{format_inline_hint(error)}" if inline_hint?(error)
        output << "\n"
      end
    else
      output << "\e[90m"
      output << line_prefix
      output << line_str
      output << "\e[0m\n"
    end
  end

  output << "\n"
  output
end

#format_source_context_basic(error) ⇒ Object

Parameters:

  • error (Object)

Returns:

  • (Object)


384
385
386
# File 'lib/herb/engine/error_formatter.rb', line 384

def format_source_context_basic(error)
  format_source_context(error)
end

#format_suggestions(errors) ⇒ Object

Parameters:

  • errors (Object)

Returns:

  • (Object)


372
373
374
375
376
377
378
379
380
381
382
# File 'lib/herb/engine/error_formatter.rb', line 372

def format_suggestions(errors)
  output = String.new
  output << "Suggestions:\n"

  errors.each do |error|
    suggestion = get_error_suggestion(error)
    output << "#{suggestion}\n" if suggestion
  end

  output
end

#get_error_suggestion(error) ⇒ Object

Parameters:

  • error (Object)

Returns:

  • (Object)


388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
# File 'lib/herb/engine/error_formatter.rb', line 388

def get_error_suggestion(error)
  case error
  when Herb::Errors::MissingClosingTagError
    if error.opening_tag
      "Add </#{error.opening_tag.value}> to close the opening tag"
    else
      "Add the missing closing tag"
    end
  when Herb::Errors::MissingOpeningTagError
    if error.closing_tag
      "Add <#{error.closing_tag.value}> before the closing tag"
    else
      "Add the missing opening tag"
    end
  when Herb::Errors::TagNamesMismatchError
    if error.opening_tag && error.closing_tag
      "Change </#{error.closing_tag.value}> to </#{error.opening_tag.value}>"
    else
      "Fix the tag name mismatch"
    end
  when Herb::Errors::VoidElementClosingTagError
    if error.tag_name
      "Remove the closing tag for void element <#{error.tag_name.value}>"
    else
      "Remove the closing tag for this void element"
    end
  when Herb::Errors::UnclosedElementError
    if error.opening_tag
      "Add </#{error.opening_tag.value}> before the end of the template"
    else
      "Close the unclosed element"
    end
  when Herb::Errors::RubyParseError
    "Check your Ruby syntax inside the ERB tag"
  when Herb::Errors::MissingAttributeValueError
    if error.attribute_name
      "Add a value after the equals sign for '#{error.attribute_name}' or remove the equals sign"
    else
      "Add a value after the equals sign or remove the equals sign"
    end
  end
end

#herb_error_to_diagnostic(error) ⇒ Object

Parameters:

  • error (Object)

Returns:

  • (Object)


312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
# File 'lib/herb/engine/error_formatter.rb', line 312

def herb_error_to_diagnostic(error)
  if error.is_a?(Hash)
    location = error[:location]
    {
      message: error[:message],
      location: {
        start: {
          line: location&.start&.line || 1,
          column: location&.start&.column || 1,
        },
        end: {
          line: location&.end&.line || location&.start&.line || 1,
          column: location&.end&.column || location&.start&.column || 1,
        },
      },
      severity: error[:severity] || "error",
      code: error[:code] || "UnknownError",
      source: error[:source] || "herb-validator",
    }
  else
    severity = case error
               when Herb::Errors::RubyParseError
                 error.level == "error" ? "error" : "warning"
               else
                 "error"
               end

    {
      message: error.message,
      location: {
        start: {
          line: error.location&.start&.line || 1,
          column: error.location&.start&.column || 1,
        },
        end: {
          line: error.location&.end&.line || error.location&.start&.line || 1,
          column: error.location&.end&.column || error.location&.start&.column || 1,
        },
      },
      severity: severity,
      code: error.class.name.split("::").last.gsub(/Error$/, ""),
      source: "herb-compiler",
    }
  end
end

#inline_hint?(error) ⇒ Boolean

Parameters:

  • error (Object)

Returns:

  • (Boolean)


225
226
227
228
229
230
231
232
233
234
235
# File 'lib/herb/engine/error_formatter.rb', line 225

def inline_hint?(error)
  case error
  when Herb::Errors::MissingClosingTagError,
       Herb::Errors::TagNamesMismatchError,
       Herb::Errors::UnclosedElementError,
       Herb::Errors::MissingAttributeValueError
    true
  else
    false
  end
end

#run_highlighter(file_path, line_num, context_lines) ⇒ Object

Parameters:

  • file_path (Object)
  • line_num (Object)
  • context_lines (Object)

Returns:

  • (Object)


268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
# File 'lib/herb/engine/error_formatter.rb', line 268

def run_highlighter(file_path, line_num, context_lines)
  return nil unless @highlighter_path && can_use_highlighter?

  cmd = "#{@highlighter_path} --focus #{line_num} --context-lines #{context_lines} \"#{file_path}\""

  begin
    output = `#{cmd} 2>/dev/null`
    status = $CHILD_STATUS
    return output.gsub(file_path, @filename) if status&.success? && !output.strip.empty?
  rescue StandardError
    # Silently fall back to basic formatting if highlighter fails
  end

  nil
end

#run_highlighter_with_diagnostics(file_path, context_lines = 2) ⇒ Object

Parameters:

  • file_path (Object)
  • context_lines (Object) (defaults to: 2)

Returns:

  • (Object)


284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
# File 'lib/herb/engine/error_formatter.rb', line 284

def run_highlighter_with_diagnostics(file_path, context_lines = 2)
  return nil unless @highlighter_path && can_use_highlighter?

  diagnostics = @errors.map { |error| herb_error_to_diagnostic(error) }

  require "tempfile"
  require "json"

  diagnostics_file = Tempfile.new(["herb_diagnostics", ".json"])
  diagnostics_file.write(JSON.pretty_generate(diagnostics))
  diagnostics_file.close

  begin
    cmd = "#{@highlighter_path} --diagnostics \"#{diagnostics_file.path}\" --split-diagnostics --context-lines #{context_lines} \"#{file_path}\""

    output = `#{cmd} 2>/dev/null`
    status = $CHILD_STATUS

    return output.gsub(file_path, @filename) if status&.success? && !output.strip.empty?
  rescue StandardError
    # Silently fall back to basic formatting if highlighter fails
  ensure
    diagnostics_file.unlink
  end

  nil
end