herb 0.6.1-x86-linux-musl → 0.7.0-x86-linux-musl

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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +1 -0
  3. data/ext/herb/nodes.c +6 -4
  4. data/lib/herb/3.0/herb.so +0 -0
  5. data/lib/herb/3.1/herb.so +0 -0
  6. data/lib/herb/3.2/herb.so +0 -0
  7. data/lib/herb/3.3/herb.so +0 -0
  8. data/lib/herb/3.4/herb.so +0 -0
  9. data/lib/herb/ast/helpers.rb +26 -0
  10. data/lib/herb/ast/nodes.rb +7 -3
  11. data/lib/herb/cli.rb +158 -1
  12. data/lib/herb/engine/compiler.rb +399 -0
  13. data/lib/herb/engine/debug_visitor.rb +321 -0
  14. data/lib/herb/engine/error_formatter.rb +420 -0
  15. data/lib/herb/engine/parser_error_overlay.rb +767 -0
  16. data/lib/herb/engine/validation_error_overlay.rb +182 -0
  17. data/lib/herb/engine/validation_errors.rb +65 -0
  18. data/lib/herb/engine/validator.rb +75 -0
  19. data/lib/herb/engine/validators/accessibility_validator.rb +31 -0
  20. data/lib/herb/engine/validators/nesting_validator.rb +95 -0
  21. data/lib/herb/engine/validators/security_validator.rb +71 -0
  22. data/lib/herb/engine.rb +366 -0
  23. data/lib/herb/project.rb +3 -3
  24. data/lib/herb/version.rb +1 -1
  25. data/lib/herb/visitor.rb +2 -0
  26. data/lib/herb.rb +2 -0
  27. data/sig/herb/ast/helpers.rbs +16 -0
  28. data/sig/herb/ast/nodes.rbs +4 -2
  29. data/sig/herb/engine/compiler.rbs +109 -0
  30. data/sig/herb/engine/debug.rbs +38 -0
  31. data/sig/herb/engine/debug_visitor.rbs +70 -0
  32. data/sig/herb/engine/error_formatter.rbs +47 -0
  33. data/sig/herb/engine/parser_error_overlay.rbs +41 -0
  34. data/sig/herb/engine/validation_error_overlay.rbs +35 -0
  35. data/sig/herb/engine/validation_errors.rbs +45 -0
  36. data/sig/herb/engine/validator.rbs +37 -0
  37. data/sig/herb/engine/validators/accessibility_validator.rbs +19 -0
  38. data/sig/herb/engine/validators/nesting_validator.rbs +25 -0
  39. data/sig/herb/engine/validators/security_validator.rbs +23 -0
  40. data/sig/herb/engine.rbs +72 -0
  41. data/sig/herb/visitor.rbs +2 -0
  42. data/sig/herb_c_extension.rbs +7 -0
  43. data/sig/serialized_ast_nodes.rbs +1 -0
  44. data/src/ast_nodes.c +2 -1
  45. data/src/ast_pretty_print.c +2 -1
  46. data/src/element_source.c +11 -0
  47. data/src/include/ast_nodes.h +3 -1
  48. data/src/include/element_source.h +13 -0
  49. data/src/include/version.h +1 -1
  50. data/src/parser.c +3 -0
  51. data/src/parser_helpers.c +1 -0
  52. metadata +30 -2
@@ -0,0 +1,420 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "tempfile"
5
+
6
+ module Herb
7
+ class Engine
8
+ class ErrorFormatter
9
+ CONTEXT_LINES = 3
10
+
11
+ def initialize(source, errors, options = {})
12
+ @source = source
13
+ @errors = errors
14
+ @filename = options[:filename] || "[source]"
15
+ @lines = source.lines
16
+ @use_highlighter = options.fetch(:use_highlighter, true)
17
+ @highlighter_path = options[:highlighter_path] || find_highlighter_path
18
+ end
19
+
20
+ def format_all
21
+ return "No errors found" if @errors.empty?
22
+
23
+ if @use_highlighter && @highlighter_path && can_use_highlighter?
24
+ format_all_with_highlighter
25
+ else
26
+ format_all_without_highlighter
27
+ end
28
+ end
29
+
30
+ def format_all_with_highlighter
31
+ output = String.new
32
+ output << "HTML+ERB Compilation Errors:\n"
33
+ output << ("=" * 60) << "\n\n"
34
+
35
+ require "tempfile"
36
+ temp_file = Tempfile.new(["herb_error", ".html.erb"])
37
+ temp_file.write(@source)
38
+ temp_file.close
39
+
40
+ begin
41
+ highlighted_output = run_highlighter_with_diagnostics(temp_file.path, CONTEXT_LINES)
42
+
43
+ if highlighted_output
44
+ output << highlighted_output
45
+ else
46
+ errors_by_line = @errors.group_by do |error|
47
+ location = error.is_a?(Hash) ? error[:location] : error.location
48
+ location&.start&.line
49
+ end.compact
50
+
51
+ errors_by_line.each_with_index do |(line_num, line_errors), group_index|
52
+ output << "Error Group ##{group_index + 1} (Line #{line_num}):\n"
53
+ output << ("-" * 40) << "\n"
54
+
55
+ line_errors.each_with_index do |error, index|
56
+ output << format_error_header(error, index + 1)
57
+ end
58
+
59
+ output << "\nSource Context:\n"
60
+
61
+ highlighted_basic = run_highlighter(temp_file.path, line_num, CONTEXT_LINES)
62
+
63
+ output << (highlighted_basic || format_source_context_basic(line_errors.first))
64
+
65
+ output << "\n"
66
+ output << format_suggestions(line_errors)
67
+ output << "\n" unless group_index == errors_by_line.length - 1
68
+ end
69
+ end
70
+
71
+ output << "\n" << ("=" * 60) << "\n"
72
+ output << "Total errors: #{@errors.length}\n"
73
+ output << "Compilation failed. Please fix the errors above.\n"
74
+ ensure
75
+ temp_file.unlink
76
+ end
77
+
78
+ output
79
+ end
80
+
81
+ def format_all_without_highlighter
82
+ output = String.new
83
+ output << "HTML+ERB Compilation Errors:\n"
84
+ output << ("=" * 60) << "\n\n"
85
+
86
+ @errors.each_with_index do |error, index|
87
+ output << format_error(error, index + 1)
88
+ output << "\n" unless index == @errors.length - 1
89
+ end
90
+
91
+ output << "\n" << ("=" * 60) << "\n"
92
+ output << "Total errors: #{@errors.length}\n"
93
+ output << "Compilation failed. Please fix the errors above.\n"
94
+
95
+ output
96
+ end
97
+
98
+ def format_error(error, number)
99
+ output = String.new
100
+
101
+ output << "Error ##{number}: #{error.class.name.split("::").last.gsub(/Error$/, "")}\n"
102
+ output << ("-" * 40) << "\n"
103
+
104
+ if error.location
105
+ output << " File: #{@filename}\n"
106
+ output << " Location: Line #{error.location.start.line}, Column #{error.location.start.column}\n"
107
+ end
108
+
109
+ output << " Message: #{error.message}\n\n"
110
+ output << format_source_context(error) if error.location
111
+ output << format_error_details(error)
112
+
113
+ output
114
+ end
115
+
116
+ private
117
+
118
+ def format_source_context(error)
119
+ output = String.new
120
+ location = error.is_a?(Hash) ? error[:location] : error.location
121
+ line_num = location.start.line
122
+ col_num = location.start.column
123
+
124
+ start_line = [line_num - CONTEXT_LINES, 1].max
125
+ end_line = [line_num + CONTEXT_LINES, @lines.length].min
126
+
127
+ output << " Source:\n"
128
+
129
+ (start_line..end_line).each do |i|
130
+ line = @lines[i - 1]
131
+ line_str = line.chomp
132
+ line_prefix = format(" %4d | ", i)
133
+
134
+ if i == line_num
135
+ output << "\e[31m"
136
+ output << line_prefix
137
+ output << line_str
138
+ output << "\e[0m\n"
139
+
140
+ if col_num.positive?
141
+ pointer = "#{" " * (line_prefix.length + col_num - 1)}^"
142
+
143
+ if location.end.column && location.end.column > col_num
144
+ underline_length = location.end.column - col_num
145
+ pointer << ("~" * [underline_length - 1, 0].max)
146
+ end
147
+
148
+ output << "\e[31m#{pointer}\e[0m"
149
+
150
+ output << " #{format_inline_hint(error)}" if inline_hint?(error)
151
+ output << "\n"
152
+ end
153
+ else
154
+ output << "\e[90m"
155
+ output << line_prefix
156
+ output << line_str
157
+ output << "\e[0m\n"
158
+ end
159
+ end
160
+
161
+ output << "\n"
162
+ output
163
+ end
164
+
165
+ def format_error_details(error)
166
+ output = String.new
167
+
168
+ case error
169
+ when Herb::Errors::MissingClosingTagError
170
+ if error.opening_tag
171
+ output << " Opening tag: <#{error.opening_tag.value}> at line #{error.opening_tag.location.start.line}\n"
172
+ output << " Expected: </#{error.opening_tag.value}>\n"
173
+ output << " Suggestion: Add the closing tag or use a self-closing tag\n"
174
+ end
175
+
176
+ when Herb::Errors::MissingOpeningTagError
177
+ if error.closing_tag
178
+ output << " Closing tag: </#{error.closing_tag.value}> at line #{error.closing_tag.location.start.line}\n"
179
+ output << " Suggestion: Add the corresponding opening tag or remove this closing tag\n"
180
+ end
181
+
182
+ when Herb::Errors::TagNamesMismatchError
183
+ if error.opening_tag && error.closing_tag
184
+ output << " Opening tag: <#{error.opening_tag.value}> at line #{error.opening_tag.location.start.line}\n"
185
+ output << " Closing tag: </#{error.closing_tag.value}> at line #{error.closing_tag.location.start.line}\n"
186
+ output << " Suggestion: Change the closing tag to </#{error.opening_tag.value}>\n"
187
+ end
188
+
189
+ when Herb::Errors::VoidElementClosingTagError
190
+ if error.tag_name
191
+ output << " Void element: <#{error.tag_name.value}>\n"
192
+ output << " Note: Void elements like <br>, <img>, <input> cannot have closing tags\n"
193
+ output << " Suggestion: Remove the closing tag or use <#{error.tag_name.value} />\n"
194
+ end
195
+
196
+ when Herb::Errors::UnclosedElementError
197
+ if error.opening_tag
198
+ output << " Opening tag: <#{error.opening_tag.value}> at line #{error.opening_tag.location.start.line}\n"
199
+ output << " Note: This element was never closed before the end of the document\n"
200
+ output << " Suggestion: Add </#{error.opening_tag.value}> before the end of the template\n"
201
+ end
202
+
203
+ when Herb::Errors::RubyParseError
204
+ output << " Ruby error: #{error.diagnostic_id}\n"
205
+ output << " Level: #{error.level}\n"
206
+ output << " Details: #{error.error_message}\n"
207
+ output << " Suggestion: Check your Ruby syntax inside the ERB tag\n"
208
+
209
+ when Herb::Errors::QuotesMismatchError
210
+ if error.opening_quote && error.closing_quote
211
+ output << " Opening quote: #{error.opening_quote.value}\n"
212
+ output << " Closing quote: #{error.closing_quote.value}\n"
213
+ output << " Suggestion: Use matching quotes for attribute values\n"
214
+ end
215
+ end
216
+
217
+ output
218
+ end
219
+
220
+ def inline_hint?(error)
221
+ case error
222
+ when Herb::Errors::MissingClosingTagError,
223
+ Herb::Errors::TagNamesMismatchError,
224
+ Herb::Errors::UnclosedElementError
225
+ true
226
+ else
227
+ false
228
+ end
229
+ end
230
+
231
+ def format_inline_hint(error)
232
+ case error
233
+ when Herb::Errors::MissingClosingTagError
234
+ "← Missing closing tag"
235
+ when Herb::Errors::TagNamesMismatchError
236
+ "← Tag mismatch"
237
+ when Herb::Errors::UnclosedElementError
238
+ "← Unclosed element"
239
+ else
240
+ ""
241
+ end
242
+ end
243
+
244
+ def find_highlighter_path
245
+ possible_paths = [
246
+ File.expand_path("../../../javascript/packages/highlighter/bin/herb-highlight", __dir__ || "."),
247
+ "herb-highlight" # In PATH
248
+ ]
249
+
250
+ possible_paths.find { |path| File.executable?(path) || system("which #{path} > /dev/null 2>&1") }
251
+ end
252
+
253
+ def can_use_highlighter?
254
+ return false unless @highlighter_path
255
+ return false unless File.exist?(@highlighter_path) || system("which #{@highlighter_path} > /dev/null 2>&1")
256
+
257
+ system("node --version > /dev/null 2>&1")
258
+ end
259
+
260
+ def run_highlighter(file_path, line_num, context_lines)
261
+ return nil unless @highlighter_path && can_use_highlighter?
262
+
263
+ cmd = "#{@highlighter_path} --focus #{line_num} --context-lines #{context_lines} \"#{file_path}\""
264
+
265
+ begin
266
+ output = `#{cmd} 2>/dev/null`
267
+ status = $CHILD_STATUS
268
+ return output.gsub(file_path, @filename) if status&.success? && !output.strip.empty?
269
+ rescue StandardError
270
+ # Silently fall back to basic formatting if highlighter fails
271
+ end
272
+
273
+ nil
274
+ end
275
+
276
+ def run_highlighter_with_diagnostics(file_path, context_lines = 2)
277
+ return nil unless @highlighter_path && can_use_highlighter?
278
+
279
+ diagnostics = @errors.map { |error| herb_error_to_diagnostic(error) }
280
+
281
+ require "tempfile"
282
+ require "json"
283
+
284
+ diagnostics_file = Tempfile.new(["herb_diagnostics", ".json"])
285
+ diagnostics_file.write(JSON.pretty_generate(diagnostics))
286
+ diagnostics_file.close
287
+
288
+ begin
289
+ cmd = "#{@highlighter_path} --diagnostics \"#{diagnostics_file.path}\" --split-diagnostics --context-lines #{context_lines} \"#{file_path}\""
290
+
291
+ output = `#{cmd} 2>/dev/null`
292
+ status = $CHILD_STATUS
293
+
294
+ return output.gsub(file_path, @filename) if status&.success? && !output.strip.empty?
295
+ rescue StandardError
296
+ # Silently fall back to basic formatting if highlighter fails
297
+ ensure
298
+ diagnostics_file.unlink
299
+ end
300
+
301
+ nil
302
+ end
303
+
304
+ def herb_error_to_diagnostic(error)
305
+ if error.is_a?(Hash)
306
+ location = error[:location]
307
+ {
308
+ message: error[:message],
309
+ location: {
310
+ start: {
311
+ line: location&.start&.line || 1,
312
+ column: location&.start&.column || 1,
313
+ },
314
+ end: {
315
+ line: location&.end&.line || location&.start&.line || 1,
316
+ column: location&.end&.column || location&.start&.column || 1,
317
+ },
318
+ },
319
+ severity: error[:severity] || "error",
320
+ code: error[:code] || "UnknownError",
321
+ source: error[:source] || "herb-validator",
322
+ }
323
+ else
324
+ severity = case error
325
+ when Herb::Errors::RubyParseError
326
+ error.level == "error" ? "error" : "warning"
327
+ else
328
+ "error"
329
+ end
330
+
331
+ {
332
+ message: error.message,
333
+ location: {
334
+ start: {
335
+ line: error.location&.start&.line || 1,
336
+ column: error.location&.start&.column || 1,
337
+ },
338
+ end: {
339
+ line: error.location&.end&.line || error.location&.start&.line || 1,
340
+ column: error.location&.end&.column || error.location&.start&.column || 1,
341
+ },
342
+ },
343
+ severity: severity,
344
+ code: error.class.name.split("::").last.gsub(/Error$/, ""),
345
+ source: "herb-compiler",
346
+ }
347
+ end
348
+ end
349
+
350
+ def format_error_header(error, number)
351
+ output = String.new
352
+ output << if error.is_a?(Hash)
353
+ " #{number}. #{error[:code] || "UnknownError"}: #{error[:message]}\n"
354
+ else
355
+ " #{number}. #{error.class.name.split("::").last.gsub(/Error$/, "")}: #{error.message}\n"
356
+ end
357
+
358
+ location = error.is_a?(Hash) ? error[:location] : error.location
359
+ output << " Location: Line #{location.start.line}, Column #{location.start.column}\n" if location
360
+
361
+ output
362
+ end
363
+
364
+ def format_suggestions(errors)
365
+ output = String.new
366
+ output << "Suggestions:\n"
367
+
368
+ errors.each do |error|
369
+ suggestion = get_error_suggestion(error)
370
+ output << " • #{suggestion}\n" if suggestion
371
+ end
372
+
373
+ output
374
+ end
375
+
376
+ def format_source_context_basic(error)
377
+ format_source_context(error)
378
+ end
379
+
380
+ def get_error_suggestion(error)
381
+ case error
382
+ when Herb::Errors::MissingClosingTagError
383
+ if error.opening_tag
384
+ "Add </#{error.opening_tag.value}> to close the opening tag"
385
+ else
386
+ "Add the missing closing tag"
387
+ end
388
+ when Herb::Errors::MissingOpeningTagError
389
+ if error.closing_tag
390
+ "Add <#{error.closing_tag.value}> before the closing tag"
391
+ else
392
+ "Add the missing opening tag"
393
+ end
394
+ when Herb::Errors::TagNamesMismatchError
395
+ if error.opening_tag && error.closing_tag
396
+ "Change </#{error.closing_tag.value}> to </#{error.opening_tag.value}>"
397
+ else
398
+ "Fix the tag name mismatch"
399
+ end
400
+ when Herb::Errors::VoidElementClosingTagError
401
+ if error.tag_name
402
+ "Remove the closing tag for void element <#{error.tag_name.value}>"
403
+ else
404
+ "Remove the closing tag for this void element"
405
+ end
406
+ when Herb::Errors::UnclosedElementError
407
+ if error.opening_tag
408
+ "Add </#{error.opening_tag.value}> before the end of the template"
409
+ else
410
+ "Close the unclosed element"
411
+ end
412
+ when Herb::Errors::RubyParseError
413
+ "Check your Ruby syntax inside the ERB tag"
414
+ when Herb::Errors::QuotesMismatchError
415
+ "Use matching quotes for attribute values"
416
+ end
417
+ end
418
+ end
419
+ end
420
+ end