herb 0.8.10-arm-linux-gnu → 0.9.0-arm-linux-gnu

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 (209) hide show
  1. checksums.yaml +4 -4
  2. data/Makefile +11 -3
  3. data/README.md +64 -34
  4. data/Rakefile +48 -40
  5. data/config.yml +317 -34
  6. data/ext/herb/error_helpers.c +367 -140
  7. data/ext/herb/error_helpers.h +1 -0
  8. data/ext/herb/extconf.rb +67 -28
  9. data/ext/herb/extension.c +317 -51
  10. data/ext/herb/extension.h +1 -0
  11. data/ext/herb/extension_helpers.c +23 -14
  12. data/ext/herb/extension_helpers.h +2 -2
  13. data/ext/herb/nodes.c +537 -270
  14. data/ext/herb/nodes.h +1 -0
  15. data/herb.gemspec +3 -2
  16. data/lib/herb/3.0/herb.so +0 -0
  17. data/lib/herb/3.1/herb.so +0 -0
  18. data/lib/herb/3.2/herb.so +0 -0
  19. data/lib/herb/3.3/herb.so +0 -0
  20. data/lib/herb/3.4/herb.so +0 -0
  21. data/lib/herb/4.0/herb.so +0 -0
  22. data/lib/herb/ast/helpers.rb +3 -3
  23. data/lib/herb/ast/node.rb +15 -2
  24. data/lib/herb/ast/nodes.rb +1132 -157
  25. data/lib/herb/bootstrap.rb +87 -0
  26. data/lib/herb/cli.rb +341 -31
  27. data/lib/herb/configuration.rb +248 -0
  28. data/lib/herb/defaults.yml +32 -0
  29. data/lib/herb/engine/compiler.rb +78 -11
  30. data/lib/herb/engine/debug_visitor.rb +13 -3
  31. data/lib/herb/engine/error_formatter.rb +13 -9
  32. data/lib/herb/engine/parser_error_overlay.rb +10 -6
  33. data/lib/herb/engine/validator.rb +8 -3
  34. data/lib/herb/engine/validators/nesting_validator.rb +2 -2
  35. data/lib/herb/engine.rb +82 -35
  36. data/lib/herb/errors.rb +563 -88
  37. data/lib/herb/lex_result.rb +1 -0
  38. data/lib/herb/location.rb +7 -3
  39. data/lib/herb/parse_result.rb +12 -2
  40. data/lib/herb/parser_options.rb +57 -0
  41. data/lib/herb/position.rb +1 -0
  42. data/lib/herb/prism_inspect.rb +116 -0
  43. data/lib/herb/project.rb +923 -331
  44. data/lib/herb/range.rb +1 -0
  45. data/lib/herb/token.rb +7 -1
  46. data/lib/herb/version.rb +1 -1
  47. data/lib/herb/visitor.rb +37 -2
  48. data/lib/herb/warnings.rb +6 -1
  49. data/lib/herb.rb +35 -3
  50. data/sig/herb/ast/helpers.rbs +2 -2
  51. data/sig/herb/ast/node.rbs +12 -2
  52. data/sig/herb/ast/nodes.rbs +641 -128
  53. data/sig/herb/bootstrap.rbs +31 -0
  54. data/sig/herb/configuration.rbs +89 -0
  55. data/sig/herb/engine/compiler.rbs +9 -1
  56. data/sig/herb/engine/debug_visitor.rbs +2 -0
  57. data/sig/herb/engine/validator.rbs +5 -1
  58. data/sig/herb/engine.rbs +17 -3
  59. data/sig/herb/errors.rbs +258 -63
  60. data/sig/herb/location.rbs +4 -0
  61. data/sig/herb/parse_result.rbs +4 -2
  62. data/sig/herb/parser_options.rbs +42 -0
  63. data/sig/herb/position.rbs +1 -0
  64. data/sig/herb/prism_inspect.rbs +28 -0
  65. data/sig/herb/range.rbs +1 -0
  66. data/sig/herb/token.rbs +6 -0
  67. data/sig/herb/visitor.rbs +25 -4
  68. data/sig/herb/warnings.rbs +6 -1
  69. data/sig/herb.rbs +14 -0
  70. data/sig/herb_c_extension.rbs +5 -2
  71. data/sig/serialized_ast_errors.rbs +54 -6
  72. data/sig/serialized_ast_nodes.rbs +60 -6
  73. data/src/analyze/action_view/attribute_extraction_helpers.c +290 -0
  74. data/src/analyze/action_view/content_tag.c +70 -0
  75. data/src/analyze/action_view/link_to.c +143 -0
  76. data/src/analyze/action_view/registry.c +60 -0
  77. data/src/analyze/action_view/tag.c +64 -0
  78. data/src/analyze/action_view/tag_helper_node_builders.c +305 -0
  79. data/src/analyze/action_view/tag_helpers.c +748 -0
  80. data/src/analyze/action_view/turbo_frame_tag.c +88 -0
  81. data/src/analyze/analyze.c +882 -0
  82. data/src/{analyzed_ruby.c → analyze/analyzed_ruby.c} +13 -11
  83. data/src/analyze/builders.c +343 -0
  84. data/src/analyze/conditional_elements.c +594 -0
  85. data/src/analyze/conditional_open_tags.c +640 -0
  86. data/src/analyze/control_type.c +250 -0
  87. data/src/{analyze_helpers.c → analyze/helpers.c} +48 -23
  88. data/src/analyze/invalid_structures.c +193 -0
  89. data/src/{analyze_missing_end.c → analyze/missing_end.c} +33 -22
  90. data/src/analyze/parse_errors.c +84 -0
  91. data/src/analyze/prism_annotate.c +397 -0
  92. data/src/{analyze_transform.c → analyze/transform.c} +17 -3
  93. data/src/ast_node.c +17 -7
  94. data/src/ast_nodes.c +662 -387
  95. data/src/ast_pretty_print.c +190 -6
  96. data/src/errors.c +1076 -520
  97. data/src/extract.c +145 -49
  98. data/src/herb.c +52 -34
  99. data/src/html_util.c +241 -12
  100. data/src/include/analyze/action_view/attribute_extraction_helpers.h +36 -0
  101. data/src/include/analyze/action_view/tag_helper_handler.h +41 -0
  102. data/src/include/analyze/action_view/tag_helper_node_builders.h +70 -0
  103. data/src/include/analyze/action_view/tag_helpers.h +38 -0
  104. data/src/include/{analyze.h → analyze/analyze.h} +14 -4
  105. data/src/include/{analyzed_ruby.h → analyze/analyzed_ruby.h} +3 -3
  106. data/src/include/analyze/builders.h +27 -0
  107. data/src/include/analyze/conditional_elements.h +9 -0
  108. data/src/include/analyze/conditional_open_tags.h +9 -0
  109. data/src/include/analyze/control_type.h +14 -0
  110. data/src/include/{analyze_helpers.h → analyze/helpers.h} +4 -2
  111. data/src/include/analyze/invalid_structures.h +11 -0
  112. data/src/include/analyze/prism_annotate.h +16 -0
  113. data/src/include/ast_node.h +11 -5
  114. data/src/include/ast_nodes.h +117 -38
  115. data/src/include/ast_pretty_print.h +5 -0
  116. data/src/include/element_source.h +3 -8
  117. data/src/include/errors.h +148 -55
  118. data/src/include/extract.h +21 -5
  119. data/src/include/herb.h +18 -6
  120. data/src/include/herb_prism_node.h +13 -0
  121. data/src/include/html_util.h +7 -2
  122. data/src/include/io.h +3 -1
  123. data/src/include/lex_helpers.h +29 -0
  124. data/src/include/lexer.h +1 -1
  125. data/src/include/lexer_peek_helpers.h +87 -13
  126. data/src/include/lexer_struct.h +2 -0
  127. data/src/include/location.h +2 -1
  128. data/src/include/parser.h +27 -2
  129. data/src/include/parser_helpers.h +19 -3
  130. data/src/include/pretty_print.h +10 -5
  131. data/src/include/prism_context.h +45 -0
  132. data/src/include/prism_helpers.h +10 -7
  133. data/src/include/prism_serialized.h +12 -0
  134. data/src/include/token.h +16 -4
  135. data/src/include/token_struct.h +10 -3
  136. data/src/include/utf8.h +2 -1
  137. data/src/include/util/hb_allocator.h +78 -0
  138. data/src/include/util/hb_arena.h +6 -1
  139. data/src/include/util/hb_arena_debug.h +12 -1
  140. data/src/include/util/hb_array.h +7 -3
  141. data/src/include/util/hb_buffer.h +6 -4
  142. data/src/include/util/hb_foreach.h +79 -0
  143. data/src/include/util/hb_narray.h +8 -4
  144. data/src/include/util/hb_string.h +56 -9
  145. data/src/include/util.h +6 -3
  146. data/src/include/version.h +1 -1
  147. data/src/io.c +3 -2
  148. data/src/lexer.c +42 -30
  149. data/src/lexer_peek_helpers.c +12 -74
  150. data/src/location.c +2 -2
  151. data/src/main.c +53 -28
  152. data/src/parser.c +783 -247
  153. data/src/parser_helpers.c +110 -23
  154. data/src/parser_match_tags.c +109 -48
  155. data/src/pretty_print.c +29 -24
  156. data/src/prism_helpers.c +30 -27
  157. data/src/ruby_parser.c +2 -0
  158. data/src/token.c +151 -66
  159. data/src/token_matchers.c +0 -1
  160. data/src/utf8.c +7 -6
  161. data/src/util/hb_allocator.c +341 -0
  162. data/src/util/hb_arena.c +81 -56
  163. data/src/util/hb_arena_debug.c +32 -17
  164. data/src/util/hb_array.c +30 -15
  165. data/src/util/hb_buffer.c +17 -21
  166. data/src/util/hb_narray.c +22 -7
  167. data/src/util/hb_string.c +49 -35
  168. data/src/util.c +21 -11
  169. data/src/visitor.c +47 -0
  170. data/templates/ext/herb/error_helpers.c.erb +24 -11
  171. data/templates/ext/herb/error_helpers.h.erb +1 -0
  172. data/templates/ext/herb/nodes.c.erb +50 -16
  173. data/templates/ext/herb/nodes.h.erb +1 -0
  174. data/templates/java/error_helpers.c.erb +1 -1
  175. data/templates/java/nodes.c.erb +30 -8
  176. data/templates/java/org/herb/ast/Errors.java.erb +24 -1
  177. data/templates/java/org/herb/ast/Nodes.java.erb +80 -21
  178. data/templates/javascript/packages/core/src/errors.ts.erb +16 -3
  179. data/templates/javascript/packages/core/src/node-type-guards.ts.erb +3 -1
  180. data/templates/javascript/packages/core/src/nodes.ts.erb +109 -32
  181. data/templates/javascript/packages/node/extension/error_helpers.cpp.erb +13 -4
  182. data/templates/javascript/packages/node/extension/nodes.cpp.erb +43 -4
  183. data/templates/lib/herb/ast/nodes.rb.erb +88 -31
  184. data/templates/lib/herb/errors.rb.erb +15 -3
  185. data/templates/lib/herb/visitor.rb.erb +2 -2
  186. data/templates/rust/src/ast/nodes.rs.erb +97 -44
  187. data/templates/rust/src/errors.rs.erb +2 -1
  188. data/templates/rust/src/nodes.rs.erb +167 -15
  189. data/templates/rust/src/union_types.rs.erb +60 -0
  190. data/templates/rust/src/visitor.rs.erb +81 -0
  191. data/templates/src/{analyze_missing_end.c.erb → analyze/missing_end.c.erb} +9 -6
  192. data/templates/src/{analyze_transform.c.erb → analyze/transform.c.erb} +2 -2
  193. data/templates/src/ast_nodes.c.erb +34 -26
  194. data/templates/src/ast_pretty_print.c.erb +24 -5
  195. data/templates/src/errors.c.erb +60 -54
  196. data/templates/src/include/ast_nodes.h.erb +6 -2
  197. data/templates/src/include/ast_pretty_print.h.erb +5 -0
  198. data/templates/src/include/errors.h.erb +15 -11
  199. data/templates/src/include/util/hb_foreach.h.erb +20 -0
  200. data/templates/src/parser_match_tags.c.erb +10 -4
  201. data/templates/src/visitor.c.erb +2 -2
  202. data/templates/template.rb +204 -29
  203. data/templates/wasm/error_helpers.cpp.erb +9 -5
  204. data/templates/wasm/nodes.cpp.erb +41 -4
  205. metadata +57 -16
  206. data/src/analyze.c +0 -1608
  207. data/src/element_source.c +0 -12
  208. data/src/include/util/hb_system.h +0 -9
  209. data/src/util/hb_system.c +0 -30
data/lib/herb/project.rb CHANGED
@@ -1,9 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
  # typed: ignore
3
-
4
3
  # rbs_inline: disabled
5
4
 
6
- require "io/console"
7
5
  require "timeout"
8
6
  require "tempfile"
9
7
  require "pathname"
@@ -12,12 +10,111 @@ require "stringio"
12
10
 
13
11
  module Herb
14
12
  class Project
15
- attr_accessor :project_path, :output_file, :no_interactive, :no_log_file, :no_timing, :silent
13
+ include Colors
14
+
15
+ attr_accessor :project_path, :output_file, :no_log_file, :no_timing, :silent, :verbose, :isolate, :validate_ruby, :file_paths, :arena_stats, :leak_check
16
+
17
+ # Known error types that indicate issues in the user's template, not bugs in the parser.
18
+ TEMPLATE_ERRORS = [
19
+ "MissingOpeningTagError",
20
+ "MissingClosingTagError",
21
+ "TagNamesMismatchError",
22
+ "VoidElementClosingTagError",
23
+ "UnclosedElementError",
24
+ "RubyParseError",
25
+ "ERBControlFlowScopeError",
26
+ "MissingERBEndTagError",
27
+ "ERBMultipleBlocksInTagError",
28
+ "ERBCaseWithConditionsError",
29
+ "ConditionalElementMultipleTagsError",
30
+ "ConditionalElementConditionMismatchError",
31
+ "InvalidCommentClosingTagError",
32
+ "OmittedClosingTagError",
33
+ "UnclosedOpenTagError",
34
+ "UnclosedCloseTagError",
35
+ "UnclosedQuoteError",
36
+ "MissingAttributeValueError",
37
+ "UnclosedERBTagError",
38
+ "StrayERBClosingTagError",
39
+ "NestedERBTagError"
40
+ ].freeze
41
+
42
+ ISSUE_TYPES = [
43
+ { key: :failed, label: "Parser crashed", symbol: "✗", color: :red, reportable: true,
44
+ hint: "This could be a bug in the parser. Reporting it helps us improve Herb for everyone.",
45
+ file_hint: ->(relative) { "Run `herb parse #{relative}` to see the parser output." } },
46
+ { key: :template_error, label: "Template errors", symbol: "✗", color: :red,
47
+ hint: "These files have issues in the template. Review the errors and update your templates to fix them." },
48
+ { key: :unexpected_error, label: "Unexpected parse errors", symbol: "✗", color: :red, reportable: true,
49
+ hint: "These errors may indicate a bug in the parser. Reporting them helps us make Herb more robust.",
50
+ file_hint: ->(relative) { "Run `herb parse #{relative}` to see the parser output." } },
51
+ { key: :strict_parse_error, label: "Strict mode parse errors", symbol: "⚠", color: :yellow,
52
+ hint: "These files use HTML patterns like omitted closing tags. Add explicit closing tags to fix." },
53
+ { key: :analyze_parse_error, label: "Analyze parse errors", symbol: "⚠", color: :yellow,
54
+ hint: "These files have issues detected during analysis. Review the errors and update your templates." },
55
+ { key: :timeout, label: "Timed out", symbol: "⚠", color: :yellow, reportable: true,
56
+ hint: "These files took too long to parse. This could indicate a parser issue. Reporting it helps us track down edge cases." },
57
+ { key: :validation_error, label: "Validation errors", symbol: "⚠", color: :yellow,
58
+ hint: "These templates have security, nesting, or accessibility issues. The templates compile fine otherwise. Review and fix these to improve your template structure." },
59
+ { key: :compilation_failed, label: "Compilation errors", symbol: "✗", color: :red, reportable: true,
60
+ hint: "These files could not be compiled to Ruby. This could be a bug in the engine. Reporting it helps us improve Herb's compatibility.",
61
+ file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compilation error." } },
62
+ { key: :strict_compilation_failed, label: "Strict mode compilation errors", symbol: "⚠", color: :yellow,
63
+ hint: "These files fail to compile only in strict mode. Add explicit closing tags to fix, or pass --no-strict to allow.",
64
+ file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compilation error." } },
65
+ { key: :invalid_ruby, label: "Invalid Ruby output", symbol: "✗", color: :red, reportable: true,
66
+ hint: "The engine produced Ruby code that doesn't parse. This is most likely a bug in the engine. Reporting it helps us fix it.",
67
+ file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compiled output." } }
68
+ ].freeze
69
+
70
+ class ResultTracker
71
+ attr_reader :successful, :failed, :timeout, :template_error, :unexpected_error,
72
+ :strict_parse_error, :analyze_parse_error,
73
+ :validation_error, :compilation_failed, :strict_compilation_failed,
74
+ :invalid_ruby,
75
+ :error_outputs, :file_contents, :parse_errors, :compilation_errors,
76
+ :file_diagnostics
77
+
78
+ def initialize
79
+ @successful = []
80
+ @failed = []
81
+ @timeout = []
82
+ @template_error = []
83
+ @unexpected_error = []
84
+ @strict_parse_error = []
85
+ @analyze_parse_error = []
86
+ @validation_error = []
87
+ @compilation_failed = []
88
+ @strict_compilation_failed = []
89
+ @invalid_ruby = []
90
+ @error_outputs = {}
91
+ @file_contents = {}
92
+ @parse_errors = {}
93
+ @compilation_errors = {}
94
+ @file_diagnostics = {}
95
+ end
96
+
97
+ def problem_files
98
+ failed + timeout + template_error + unexpected_error + strict_parse_error + analyze_parse_error +
99
+ validation_error + compilation_failed + strict_compilation_failed + invalid_ruby
100
+ end
16
101
 
17
- def interactive?
18
- return false if no_interactive
102
+ def file_issue_type(file)
103
+ ISSUE_TYPES.find { |type| send(type[:key]).include?(file) }
104
+ end
105
+
106
+ def diagnostic_counts
107
+ counts = Hash.new { |hash, key| hash[key] = { count: 0, files: Set.new } }
108
+
109
+ file_diagnostics.each do |file, diagnostics|
110
+ diagnostics.each do |diagnostic|
111
+ counts[diagnostic[:name]][:count] += 1
112
+ counts[diagnostic[:name]][:files] << file
113
+ end
114
+ end
19
115
 
20
- !IO.console.nil?
116
+ counts.sort_by { |_name, value| -value[:count] }
117
+ end
21
118
  end
22
119
 
23
120
  def initialize(project_path, output_file: nil)
@@ -29,12 +126,16 @@ module Herb
29
126
  @output_file = output_file || "#{date}_erb_parsing_result_#{@project_path.basename}.log"
30
127
  end
31
128
 
32
- def glob
33
- "**/*.{html,rhtml,html.erb,html+*.erb,turbo_stream.erb}"
129
+ def configuration
130
+ @configuration ||= Configuration.load(@project_path.to_s)
131
+ end
132
+
133
+ def include_patterns
134
+ configuration.file_include_patterns
34
135
  end
35
136
 
36
- def full_path_glob
37
- project_path + glob
137
+ def exclude_patterns
138
+ configuration.file_exclude_patterns
38
139
  end
39
140
 
40
141
  def absolute_path
@@ -42,10 +143,27 @@ module Herb
42
143
  end
43
144
 
44
145
  def files
45
- @files ||= Dir[full_path_glob]
146
+ @files ||= file_paths || find_files
46
147
  end
47
148
 
48
- def parse!
149
+ private
150
+
151
+ def find_files
152
+ included = include_patterns.flat_map do |pattern|
153
+ Dir[File.join(@project_path, pattern)]
154
+ end.uniq
155
+
156
+ return included if exclude_patterns.empty?
157
+
158
+ included.reject do |file|
159
+ relative_path = file.sub("#{@project_path}/", "")
160
+ exclude_patterns.any? { |pattern| File.fnmatch?(pattern, relative_path, File::FNM_PATHNAME) }
161
+ end
162
+ end
163
+
164
+ public
165
+
166
+ def analyze!
49
167
  start_time = Time.now unless no_timing
50
168
 
51
169
  log = if no_log_file
@@ -61,399 +179,681 @@ module Herb
61
179
 
62
180
  log.puts heading("PROJECT")
63
181
  log.puts "Path: #{absolute_path}"
64
- log.puts "Glob: #{"#{absolute_path}#{glob}"}\n\n"
182
+ log.puts "Config: #{configuration.config_path || "(defaults)"}"
183
+ log.puts "Include: #{include_patterns.join(", ")}"
184
+ log.puts "Exclude: #{exclude_patterns.join(", ")}\n\n"
65
185
 
66
186
  log.puts heading("PROCESSED FILES")
67
187
 
68
188
  if files.empty?
69
- message = "No .html.erb files found using #{full_path_glob}"
189
+ message = "No files found matching patterns: #{include_patterns.join(", ")}"
70
190
  log.puts message
71
191
  puts message
72
192
  return
73
193
  end
74
194
 
75
- print "\e[H\e[2J" if interactive?
76
-
77
- successful_files = []
78
- failed_files = []
79
- timeout_files = []
80
- error_files = []
81
- compilation_failed_files = []
82
- error_outputs = {}
83
- file_contents = {}
84
- parse_errors = {}
85
- compilation_errors = {}
86
-
87
- files.each_with_index do |file_path, index|
88
- total_failed = failed_files.count
89
- total_timeout = timeout_files.count
90
- total_errors = error_files.count
91
- total_compilation_failed = compilation_failed_files.count
92
-
93
- lines_to_clear = 6 + total_failed + total_timeout + total_errors + total_compilation_failed
94
- lines_to_clear += 3 if total_failed.positive?
95
- lines_to_clear += 3 if total_timeout.positive?
96
- lines_to_clear += 3 if total_errors.positive?
97
- lines_to_clear += 3 if total_compilation_failed.positive?
98
-
99
- lines_to_clear.times { print "\e[1A\e[K" } if index.positive? && interactive?
100
-
101
- if interactive?
102
- puts "Parsing .html.erb files in: #{project_path}"
103
- puts "Total files to process: #{files.count}\n"
104
-
105
- relative_path = file_path.sub("#{project_path}/", "")
106
-
107
- puts
108
- puts progress_bar(index + 1, files.count)
109
- puts
195
+ @results = ResultTracker.new
196
+ results = @results
197
+
198
+ unless silent
199
+ puts ""
200
+ puts "#{bold("Herb")} 🌿 #{dimmed("v#{Herb::VERSION}")}"
201
+ puts ""
202
+
203
+ if configuration.config_path
204
+ puts "#{green("✓")} Using Herb config file at #{dimmed(configuration.config_path)}"
110
205
  else
111
- relative_path = file_path.sub("#{project_path}/", "")
206
+ puts dimmed("No .herb.yml found, using defaults")
112
207
  end
113
- puts "Processing [#{index + 1}/#{files.count}]: #{relative_path}" unless silent
114
-
115
- if interactive?
116
- if failed_files.any?
117
- puts
118
- puts "Files that failed:"
119
- failed_files.each { |file| puts " - #{file}" }
120
- puts
121
- end
122
208
 
123
- if timeout_files.any?
124
- puts
125
- puts "Files that timed out:"
126
- timeout_files.each { |file| puts " - #{file}" }
127
- puts
128
- end
209
+ puts dimmed("Analyzing #{files.count} #{pluralize(files.count, "file")}...")
210
+ end
129
211
 
130
- if error_files.any?
131
- puts
132
- puts "Files with parse errors:"
133
- error_files.each { |file| puts " - #{file}" }
134
- puts
135
- end
212
+ total_width = files.count.to_s.length
136
213
 
137
- if compilation_failed_files.any?
138
- puts
139
- puts "Files with compilation errors:"
140
- compilation_failed_files.each { |file| puts " - #{file}" }
141
- puts
142
- end
214
+ finish_hook = lambda do |item, index, _file_result|
215
+ next if silent
216
+
217
+ if verbose
218
+ relative_path = relative_path(item)
219
+ puts " #{dimmed("[#{(index + 1).to_s.rjust(total_width)}/#{files.count}]")} #{relative_path}"
220
+ else
221
+ print "."
143
222
  end
223
+ end
144
224
 
145
- begin
146
- file_content = File.read(file_path)
225
+ ensure_parallel!
147
226
 
148
- stdout_file = Tempfile.new("stdout")
149
- stderr_file = Tempfile.new("stderr")
150
- ast_file = Tempfile.new("ast")
227
+ file_results = Parallel.map(files, in_processes: Parallel.processor_count, finish: finish_hook) do |file_path|
228
+ process_file(file_path)
229
+ end
151
230
 
152
- Timeout.timeout(1) do
153
- pid = Process.fork do
154
- $stdout.reopen(stdout_file.path, "w")
155
- $stderr.reopen(stderr_file.path, "w")
231
+ unless silent
232
+ puts "" unless verbose
233
+ puts ""
234
+ puts separator
235
+ end
156
236
 
157
- begin
158
- result = Herb.parse(file_content)
237
+ file_results.each do |result|
238
+ merge_file_result(result, results, log)
239
+ end
159
240
 
160
- if result.failed?
161
- File.open(ast_file.path, "w") do |f|
162
- f.puts result.value.inspect
163
- end
241
+ log.puts ""
164
242
 
165
- exit!(2)
166
- end
243
+ duration = no_timing ? nil : Time.now - start_time
167
244
 
168
- exit!(0)
169
- rescue StandardError => e
170
- warn "Ruby exception: #{e.class}: #{e.message}"
171
- warn e.backtrace.join("\n") if e.backtrace
172
- exit!(1)
173
- end
174
- end
245
+ print_file_lists(results, log)
175
246
 
176
- Process.waitpid(pid)
177
-
178
- stdout_file.rewind
179
- stderr_file.rewind
180
- stdout_content = stdout_file.read
181
- stderr_content = stderr_file.read
182
- ast = File.exist?(ast_file.path) ? File.read(ast_file.path) : ""
183
-
184
- case $CHILD_STATUS.exitstatus
185
- when 0
186
- log.puts "✅ Parsed #{file_path} successfully"
187
-
188
- begin
189
- Herb::Engine.new(file_content, filename: file_path, escape: true)
190
-
191
- log.puts "✅ Compiled #{file_path} successfully"
192
- successful_files << file_path
193
- rescue Herb::Engine::CompilationError => e
194
- log.puts "❌ Compilation failed for #{file_path}"
195
-
196
- compilation_failed_files << file_path
197
- compilation_errors[file_path] = {
198
- error: e.message,
199
- backtrace: e.backtrace&.first(10) || [],
200
- }
201
-
202
- file_contents[file_path] = file_content
203
- rescue StandardError => e
204
- log.puts "❌ Unexpected compilation error for #{file_path}: #{e.class}: #{e.message}"
205
-
206
- compilation_failed_files << file_path
207
- compilation_errors[file_path] = {
208
- error: "#{e.class}: #{e.message}",
209
- backtrace: e.backtrace&.first(10) || [],
210
- }
211
-
212
- file_contents[file_path] = file_content
213
- end
214
- when 2
215
- message = "⚠️ Parsing #{file_path} completed with errors"
216
- log.puts message
217
-
218
- parse_errors[file_path] = {
219
- ast: ast,
220
- stdout: stdout_content,
221
- stderr: stderr_content,
222
- }
223
-
224
- file_contents[file_path] = file_content
225
-
226
- error_files << file_path
227
- else
228
- message = "❌ Parsing #{file_path} failed"
229
- log.puts message
230
-
231
- error_outputs[file_path] = {
232
- exit_code: $CHILD_STATUS.exitstatus,
233
- stdout: stdout_content,
234
- stderr: stderr_content,
235
- }
236
-
237
- file_contents[file_path] = file_content
238
-
239
- failed_files << file_path
240
- end
241
- end
247
+ if results.problem_files.any?
248
+ puts "\n #{separator}"
249
+ print_issue_summary(results)
242
250
 
243
- stdout_file.close
244
- stdout_file.unlink
245
- stderr_file.close
246
- stderr_file.unlink
247
- ast_file.close
248
- ast_file.unlink
249
- rescue Timeout::Error
250
- message = "⏱️ Parsing #{file_path} timed out after 1 second"
251
- log.puts message
252
-
253
- begin
254
- Process.kill("TERM", pid)
255
- rescue StandardError
256
- nil
257
- end
251
+ if reportable_files?(results)
252
+ puts "\n #{separator}"
253
+ print_reportable_files(results)
254
+ end
255
+ end
258
256
 
259
- timeout_files << file_path
260
- file_contents[file_path] = file_content
261
- rescue StandardError => e
262
- message = "⚠️ Error processing #{file_path}: #{e.message}"
263
- log.puts message
257
+ log_problem_file_details(results, log)
264
258
 
265
- failed_files << file_path
259
+ if arena_stats
260
+ print_arena_summary(file_results)
261
+ end
266
262
 
267
- begin
268
- file_contents[file_path] = File.read(file_path)
269
- rescue StandardError => read_error
270
- log.puts " Could not read file content: #{read_error.message}"
271
- end
272
- end
263
+ if leak_check
264
+ print_leak_check_summary(file_results)
273
265
  end
274
266
 
275
- if interactive?
276
- print "\e[1A\e[K"
277
- puts "Completed processing all files."
278
- print "\e[H\e[2J"
279
- else
280
- puts "Completed processing all files." unless silent
267
+ unless no_log_file
268
+ puts "\n #{separator}"
269
+ puts "\n #{dimmed("Results saved to #{output_file}")}"
281
270
  end
282
271
 
283
- log.puts ""
272
+ puts "\n #{separator}"
273
+ print_summary(results, log, duration)
284
274
 
285
- summary = [
286
- heading("Summary"),
287
- "Total files: #{files.count}",
288
- "✅ Successful (parsed & compiled): #{successful_files.count} (#{percentage(successful_files.count,
289
- files.count)}%)",
290
- "❌ Compilation errors: #{compilation_failed_files.count} (#{percentage(compilation_failed_files.count,
291
- files.count)}%)",
292
- "❌ Failed to parse: #{failed_files.count} (#{percentage(failed_files.count, files.count)}%)",
293
- "⚠️ Parse errors: #{error_files.count} (#{percentage(error_files.count, files.count)}%)",
294
- "⏱️ Timed out: #{timeout_files.count} (#{percentage(timeout_files.count, files.count)}%)"
295
- ]
296
-
297
- summary.each do |line|
298
- log.puts line
299
- puts line
300
- end
275
+ results.problem_files.any?
276
+ ensure
277
+ log.close unless no_log_file
278
+ end
279
+ end
301
280
 
302
- if failed_files.any?
303
- log.puts "\n#{heading("Files that failed")}"
304
- puts "\nFiles that failed:"
281
+ def print_file_report(file_path)
282
+ file_path = File.expand_path(file_path)
283
+ results = @results
305
284
 
306
- failed_files.each do |f|
307
- log.puts "- #{f}"
308
- puts " - #{f}"
285
+ unless results
286
+ puts "No results available. Run parse! first."
287
+ return
288
+ end
289
+
290
+ relative = relative_path(file_path)
291
+ issue_type = results.file_issue_type(file_path)
292
+
293
+ unless issue_type
294
+ puts "No issues found for #{relative}."
295
+ return
296
+ end
297
+
298
+ diagnostics = results.file_diagnostics[file_path]
299
+ file_content = results.file_contents[file_path]
300
+
301
+ puts "- **Herb:** `#{Herb.version}`"
302
+ puts "- **Ruby:** `#{RUBY_VERSION}`"
303
+ puts "- **Platform:** `#{RUBY_PLATFORM}`"
304
+ puts "- **Category:** `#{issue_type[:label]}`"
305
+
306
+ if diagnostics&.any?
307
+ puts ""
308
+ puts "**Errors:**"
309
+ diagnostics.each do |diagnostic|
310
+ lines = diagnostic[:message].split("\n")
311
+ puts "- **#{diagnostic[:name]}** #{lines.first}"
312
+ lines.drop(1).each do |line|
313
+ puts " #{line}"
309
314
  end
310
315
  end
316
+ end
311
317
 
312
- if error_files.any?
313
- log.puts "\n#{heading("Files with parse errors")}"
314
- puts "\nFiles with parse errors:"
318
+ if file_content
319
+ puts ""
320
+ puts "**Template:**"
321
+ puts "```erb"
322
+ puts file_content
323
+ puts "```"
324
+ end
315
325
 
316
- error_files.each do |f|
317
- log.puts f
318
- puts " - #{f}"
319
- end
326
+ return unless issue_type[:key] == :invalid_ruby && file_content
327
+
328
+ begin
329
+ engine = Herb::Engine.new(file_content, filename: file_path, escape: true, validation_mode: :none)
330
+ puts ""
331
+ puts "**Compiled Ruby:**"
332
+ puts "```ruby"
333
+ puts engine.src
334
+ puts "```"
335
+ rescue StandardError
336
+ # Skip if compilation fails entirely
337
+ end
338
+ end
339
+
340
+ private
341
+
342
+ def process_file(file_path)
343
+ isolate ? process_file_isolated(file_path) : process_file_direct(file_path)
344
+ end
345
+
346
+ def process_file_direct(file_path)
347
+ file_content = File.read(file_path)
348
+ result = { file_path: file_path }
349
+
350
+ if arena_stats
351
+ result[:arena_stats] = capture_arena_stats(file_content)
352
+ end
353
+
354
+ if leak_check
355
+ result[:leak_check] = capture_leak_check(file_content)
356
+ end
357
+
358
+ Timeout.timeout(1) do
359
+ parse_result = Herb.parse(file_content)
360
+
361
+ if parse_result.failed?
362
+ result[:file_content] = file_content
363
+ result.merge!(classify_parse_errors(file_path, file_content))
364
+ else
365
+ result[:log] = "✅ Parsed #{file_path} successfully"
366
+ result.merge!(compile_file(file_path, file_content))
320
367
  end
368
+ end
369
+
370
+ result
371
+ rescue Timeout::Error
372
+ result.merge(status: :timeout, file_content: file_content,
373
+ log: "⏱️ Parsing #{file_path} timed out after 1 second")
374
+ rescue StandardError => e
375
+ file_content ||= begin
376
+ File.read(file_path)
377
+ rescue StandardError
378
+ nil
379
+ end
380
+
381
+ result.merge(status: :failed, file_content: file_content,
382
+ log: "⚠️ Error processing #{file_path}: #{e.message}")
383
+ end
384
+
385
+ def process_file_isolated(file_path)
386
+ file_content = File.read(file_path)
387
+ result = { file_path: file_path }
321
388
 
322
- if timeout_files.any?
323
- log.puts "\n#{heading("Files that timed out")}"
324
- puts "\nFiles that timed out:"
389
+ stdout_file = Tempfile.new("stdout")
390
+ stderr_file = Tempfile.new("stderr")
325
391
 
326
- timeout_files.each do |f|
327
- log.puts f
328
- puts " - #{f}"
392
+ Timeout.timeout(1) do
393
+ pid = Process.fork do
394
+ $stdout.reopen(stdout_file.path, "w")
395
+ $stderr.reopen(stderr_file.path, "w")
396
+
397
+ begin
398
+ parse_result = Herb.parse(file_content)
399
+ exit!(parse_result.failed? ? 2 : 0)
400
+ rescue StandardError => e
401
+ warn "Ruby exception: #{e.class}: #{e.message}"
402
+ warn e.backtrace.join("\n") if e.backtrace
403
+ exit!(1)
329
404
  end
330
405
  end
331
406
 
332
- if compilation_failed_files.any?
333
- log.puts "\n#{heading("Files with compilation errors")}"
334
- puts "\nFiles with compilation errors:"
407
+ Process.waitpid(pid)
335
408
 
336
- compilation_failed_files.each do |f|
337
- log.puts f
338
- puts " - #{f}"
339
- end
409
+ stderr_file.rewind
410
+ stderr_content = stderr_file.read
411
+
412
+ case $CHILD_STATUS.exitstatus
413
+ when 0
414
+ result[:log] = "✅ Parsed #{file_path} successfully"
415
+ result.merge!(compile_file(file_path, file_content))
416
+ when 2
417
+ result[:file_content] = file_content
418
+ result.merge!(classify_parse_errors(file_path, file_content))
419
+ else
420
+ result[:log] = "❌ Parsing #{file_path} failed"
421
+ result[:status] = :failed
422
+ result[:file_content] = file_content
423
+ result[:error_output] = { exit_code: $CHILD_STATUS.exitstatus, stderr: stderr_content }
340
424
  end
425
+ end
341
426
 
342
- problem_files = failed_files + timeout_files + error_files + compilation_failed_files
427
+ result
428
+ rescue Timeout::Error
429
+ begin
430
+ Process.kill("TERM", pid)
431
+ rescue StandardError
432
+ nil
433
+ end
343
434
 
344
- if problem_files.any?
345
- log.puts "\n#{heading("FILE CONTENTS AND DETAILS")}"
435
+ { file_path: file_path, status: :timeout, file_content: file_content,
436
+ log: "⏱️ Parsing #{file_path} timed out after 1 second" }
437
+ rescue StandardError => e
438
+ file_content ||= begin
439
+ File.read(file_path)
440
+ rescue StandardError
441
+ nil
442
+ end
346
443
 
347
- problem_files.each do |file|
348
- next unless file_contents[file]
444
+ { file_path: file_path, status: :failed, file_content: file_content,
445
+ log: "⚠️ Error processing #{file_path}: #{e.message}" }
446
+ ensure
447
+ [stdout_file, stderr_file].each do |tempfile|
448
+ next unless tempfile
349
449
 
350
- divider = "=" * [80, file.length].max
450
+ tempfile.close
451
+ tempfile.unlink
452
+ end
453
+ end
351
454
 
352
- log.puts
353
- log.puts divider
354
- log.puts file
355
- log.puts divider
455
+ def classify_parse_errors(file_path, file_content)
456
+ default_result = Herb.parse(file_content)
457
+
458
+ diagnostics = if default_result.respond_to?(:errors) && default_result.errors.any?
459
+ default_result.errors.map do |error|
460
+ diagnostic = { name: error.error_name, message: error.message }
461
+ if error.respond_to?(:location) && error.location
462
+ diagnostic[:line] = error.location.start.line
463
+ diagnostic[:column] = error.location.start.column
464
+ end
465
+ diagnostic
466
+ end
467
+ end
356
468
 
357
- log.puts "\n#{heading("CONTENT")}"
358
- log.puts "```erb"
359
- log.puts file_contents[file]
360
- log.puts "```"
469
+ no_strict_result = Herb.parse(file_content, strict: false)
470
+ no_analyze_result = Herb.parse(file_content, analyze: false)
471
+
472
+ if no_strict_result.success?
473
+ { status: :strict_parse_error, diagnostics: diagnostics,
474
+ log: "⚠️ Parsing #{file_path} completed with strict mode errors" }
475
+ elsif no_analyze_result.success?
476
+ { status: :analyze_parse_error, diagnostics: diagnostics,
477
+ log: "⚠️ Parsing #{file_path} completed with analyze errors" }
478
+ elsif diagnostics&.any? && diagnostics.all? { |diagnostic| TEMPLATE_ERRORS.include?(diagnostic[:name]) }
479
+ { status: :template_error, diagnostics: diagnostics,
480
+ log: "⚠️ Parsing #{file_path} completed with template errors" }
481
+ else
482
+ { status: :unexpected_error, diagnostics: diagnostics,
483
+ log: "❌ Parsing #{file_path} completed with unexpected errors" }
484
+ end
485
+ end
361
486
 
362
- if error_outputs[file]
363
- if error_outputs[file][:exit_code]
364
- log.puts "\n#{heading("EXIT CODE")}"
365
- log.puts error_outputs[file][:exit_code]
366
- end
487
+ def compile_file(file_path, file_content)
488
+ Herb::Engine.new(file_content, filename: file_path, escape: true, validate_ruby: validate_ruby)
367
489
 
368
- if error_outputs[file][:stderr].strip.length.positive?
369
- log.puts "\n#{heading("ERROR OUTPUT")}"
370
- log.puts "```"
371
- log.puts error_outputs[file][:stderr]
372
- log.puts "```"
373
- end
490
+ { status: :successful, log: "✅ Compiled #{file_path} successfully" }
491
+ rescue Herb::Engine::InvalidRubyError => e
492
+ { status: :invalid_ruby, file_content: file_content,
493
+ compilation_error: { error: e.message, backtrace: e.backtrace&.first(10) || [] },
494
+ diagnostics: [{ name: "InvalidRubyError", message: e.message }],
495
+ log: "🚨 Compiled Ruby is invalid for #{file_path}" }
496
+ rescue Herb::Engine::SecurityError, Herb::Engine::CompilationError => e
497
+ compilation_error = { error: e.message, backtrace: e.backtrace&.first(10) || [] }
374
498
 
375
- if error_outputs[file][:stdout].strip.length.positive?
376
- log.puts "\n#{heading("STANDARD OUTPUT")}"
377
- log.puts "```"
378
- log.puts error_outputs[file][:stdout]
379
- log.puts "```"
380
- log.puts
381
- end
382
- end
499
+ # Retry without validators
500
+ begin
501
+ Herb::Engine.new(file_content, filename: file_path, escape: true, validation_mode: :none, validate_ruby: validate_ruby)
502
+ error_name = e.is_a?(Herb::Engine::SecurityError) ? "SecurityError" : "ValidationError"
503
+ return { status: :validation_error, file_content: file_content,
504
+ compilation_error: compilation_error,
505
+ diagnostics: [{ name: error_name, message: e.message }],
506
+ log: "⚠️ Compilation failed for #{file_path} (validation error)" }
507
+ rescue StandardError
508
+ # Not a validator-caused error, continue with other checks
509
+ end
383
510
 
384
- if parse_errors[file]
385
- if parse_errors[file][:stdout].strip.length.positive?
386
- log.puts "\n#{heading("STANDARD OUTPUT")}"
387
- log.puts "```"
388
- log.puts parse_errors[file][:stdout]
389
- log.puts "```"
390
- end
511
+ # Retry without strict mode
512
+ begin
513
+ Herb::Engine.new(file_content, filename: file_path, escape: true, strict: false, validate_ruby: validate_ruby)
514
+ return { status: :strict_compilation_failed, file_content: file_content,
515
+ compilation_error: compilation_error,
516
+ diagnostics: [{ name: "CompilationError", message: "#{e.message} (strict mode)" }],
517
+ log: "🔒 Compilation failed for #{file_path} (strict mode error)" }
518
+ rescue StandardError
519
+ # Fall through
520
+ end
391
521
 
392
- if parse_errors[file][:stderr].strip.length.positive?
393
- log.puts "\n#{heading("ERROR OUTPUT")}"
394
- log.puts "```"
395
- log.puts parse_errors[file][:stderr]
396
- log.puts "```"
397
- end
522
+ { status: :compilation_failed, file_content: file_content,
523
+ compilation_error: compilation_error,
524
+ diagnostics: [{ name: "CompilationError", message: e.message }],
525
+ log: "❌ Compilation failed for #{file_path}" }
526
+ rescue StandardError => e
527
+ { status: :compilation_failed, file_content: file_content,
528
+ compilation_error: { error: "#{e.class}: #{e.message}", backtrace: e.backtrace&.first(10) || [] },
529
+ diagnostics: [{ name: e.class.to_s, message: e.message }],
530
+ log: "❌ Unexpected compilation error for #{file_path}: #{e.class}: #{e.message}" }
531
+ end
398
532
 
399
- if parse_errors[file][:ast]
400
- log.puts "\n#{heading("AST")}"
401
- log.puts "```"
402
- log.puts parse_errors[file][:ast]
403
- log.puts "```"
404
- log.puts
405
- end
406
- end
533
+ def merge_file_result(result, tracker, log)
534
+ file_path = result[:file_path]
535
+ status = result[:status]
536
+
537
+ log.puts result[:log] if result[:log]
538
+
539
+ return unless status
540
+
541
+ tracker.send(status) << file_path
542
+
543
+ tracker.file_contents[file_path] = result[:file_content] if result[:file_content]
544
+ tracker.error_outputs[file_path] = result[:error_output] if result[:error_output]
545
+ tracker.parse_errors[file_path] = result[:parse_error] if result[:parse_error]
546
+ tracker.compilation_errors[file_path] = result[:compilation_error] if result[:compilation_error]
547
+ tracker.file_diagnostics[file_path] = result[:diagnostics] if result[:diagnostics]&.any?
548
+ end
549
+
550
+ def print_summary(results, log, duration)
551
+ total = files.count
552
+ issues = results.problem_files.count
553
+ passed = results.successful.count
554
+
555
+ log_summary(results, log, total, duration)
556
+
557
+ parsed = total - results.failed.count - results.timeout.count
558
+
559
+ puts "\n"
560
+ puts " #{bold("Summary:")}"
561
+
562
+ puts " #{label("Version")} #{cyan(Herb.version)}"
563
+ puts " #{label("Checked")} #{cyan("#{total} #{pluralize(total, "file")}")}"
407
564
 
408
- next unless compilation_errors[file]
565
+ if total > 1
566
+ files_line = if issues.positive?
567
+ "#{bold(green("#{passed} clean"))} | #{bold(red("#{issues} with issues"))}"
568
+ else
569
+ bold(green("#{total} clean"))
570
+ end
409
571
 
410
- log.puts "\n#{heading("COMPILATION ERROR")}"
411
- log.puts "```"
412
- log.puts compilation_errors[file][:error]
413
- log.puts "```"
572
+ puts " #{label("Files")} #{files_line}"
573
+ end
574
+
575
+ parser_parts = []
576
+ parser_parts << stat(parsed, "parsed", :green)
577
+ parser_parts << stat(results.failed.count, "crashed", :red) if results.failed.any?
578
+ parser_parts << stat(results.template_error.count, pluralize(results.template_error.count, "template error"), :red) if results.template_error.any?
579
+ parser_parts << stat(results.unexpected_error.count, "unexpected", :red) if results.unexpected_error.any?
580
+ parser_parts << stat(results.strict_parse_error.count, "strict", :yellow) if results.strict_parse_error.any?
581
+ parser_parts << stat(results.analyze_parse_error.count, "analyze", :yellow) if results.analyze_parse_error.any?
582
+ puts " #{label("Parser")} #{parser_parts.join(" | ")}"
583
+
584
+ skipped = total - passed - results.validation_error.count - results.compilation_failed.count -
585
+ results.strict_compilation_failed.count - results.invalid_ruby.count
586
+
587
+ engine_parts = []
588
+ engine_parts << stat(passed, "compiled", :green)
589
+ engine_parts << stat(results.validation_error.count, "validation", :yellow) if results.validation_error.any?
590
+ engine_parts << stat(results.compilation_failed.count, "compilation", :red) if results.compilation_failed.any?
591
+ engine_parts << stat(results.strict_compilation_failed.count, "strict", :yellow) if results.strict_compilation_failed.any?
592
+ engine_parts << stat(results.invalid_ruby.count, "produced invalid Ruby", :red) if results.invalid_ruby.any?
593
+ engine_parts << dimmed("#{skipped} skipped") if skipped.positive?
594
+ puts " #{label("Engine")} #{engine_parts.join(" | ")}"
595
+
596
+ if results.timeout.any?
597
+ puts " #{label("Timeout")} #{stat(results.timeout.count, "timed out", :yellow)}"
598
+ end
599
+
600
+ if duration
601
+ puts " #{label("Duration")} #{cyan(format_duration(duration))}"
602
+ end
603
+
604
+ return unless issues.zero? && total > 1
605
+
606
+ puts ""
607
+ puts " #{bold(green("✓"))} #{green("All files are clean!")}"
608
+ end
609
+
610
+ def log_summary(results, log, total, duration)
611
+ log.puts heading("Summary")
612
+ log.puts "Herb Version: #{Herb.version}"
613
+ log.puts "Total files: #{total}"
614
+ log.puts "Parser options: strict: true, analyze: true"
615
+ log.puts ""
616
+ log.puts "✅ Successful (parsed & compiled): #{results.successful.count} (#{percentage(results.successful.count, total)}%)"
617
+ log.puts ""
618
+ log.puts "--- Parser ---"
619
+ log.puts "❌ Parser crashed: #{results.failed.count} (#{percentage(results.failed.count, total)}%)"
620
+ log.puts "⚠️ Template errors: #{results.template_error.count} (#{percentage(results.template_error.count, total)}%)"
621
+ log.puts "❌ Unexpected parse errors: #{results.unexpected_error.count} (#{percentage(results.unexpected_error.count, total)}%)"
622
+ log.puts "🔒 Strict mode parse errors (ok with strict: false): #{results.strict_parse_error.count} (#{percentage(results.strict_parse_error.count, total)}%)"
623
+ log.puts "🔍 Analyze parse errors (ok with analyze: false): #{results.analyze_parse_error.count} (#{percentage(results.analyze_parse_error.count, total)}%)"
624
+ log.puts ""
625
+ log.puts "--- Engine ---"
626
+ log.puts "⚠️ Validation errors (ok without validators): #{results.validation_error.count} (#{percentage(results.validation_error.count, total)}%)"
627
+ log.puts "❌ Compilation errors: #{results.compilation_failed.count} (#{percentage(results.compilation_failed.count, total)}%)"
628
+ log.puts "🔒 Strict mode compilation errors (ok with strict: false): #{results.strict_compilation_failed.count} (#{percentage(results.strict_compilation_failed.count, total)}%)"
629
+ log.puts "🚨 Invalid Ruby output: #{results.invalid_ruby.count} (#{percentage(results.invalid_ruby.count, total)}%)"
630
+ log.puts ""
631
+ log.puts "--- Other ---"
632
+ log.puts "⏱️ Timed out: #{results.timeout.count} (#{percentage(results.timeout.count, total)}%)"
633
+
634
+ return unless duration
635
+
636
+ log.puts "\n⏱️ Total time: #{format_duration(duration)}"
637
+ end
638
+
639
+ def print_file_lists(results, log)
640
+ log_file_lists(results, log)
641
+
642
+ return unless results.problem_files.any?
643
+
644
+ printed_section = false
645
+
646
+ ISSUE_TYPES.each do |type|
647
+ file_list = results.send(type[:key])
648
+ next unless file_list.any?
649
+
650
+ puts "\n #{separator}" if printed_section
651
+ printed_section = true
652
+
653
+ puts "\n"
654
+ puts " #{bold("#{type[:label]}:")}"
655
+ puts " #{dimmed(type[:hint])}" if type[:hint]
414
656
 
415
- if compilation_errors[file][:backtrace].any?
416
- log.puts "\n#{heading("BACKTRACE")}"
417
- log.puts "```"
418
- log.puts compilation_errors[file][:backtrace].join("\n")
419
- log.puts "```"
657
+ file_list.each do |file|
658
+ relative = relative_path(file)
659
+ diagnostics = results.file_diagnostics[file]
660
+
661
+ puts ""
662
+ puts " #{cyan(relative)}:"
663
+
664
+ if diagnostics&.any?
665
+ diagnostics.each do |diagnostic|
666
+ severity = send(type[:color], type[:symbol])
667
+ location = diagnostic[:line] ? dimmed("at #{diagnostic[:line]}:#{diagnostic[:column]}") : nil
668
+ lines = diagnostic[:message].split("\n")
669
+ puts " #{severity} #{bold(diagnostic[:name])} #{location}#{" #{dimmed("-")} " if location}#{dimmed(lines.first)}"
670
+ lines.drop(1).each do |line|
671
+ puts " #{dimmed(line)}"
672
+ end
420
673
  end
421
- log.puts
674
+ else
675
+ severity = send(type[:color], type[:symbol])
676
+ puts " #{severity} #{type[:label]}"
422
677
  end
423
- end
424
678
 
425
- unless no_timing
426
- end_time = Time.now
427
- duration = end_time - start_time
428
- timing_message = "\n⏱️ Total time: #{format_duration(duration)}"
429
- log.puts timing_message
430
- puts timing_message
679
+ puts "\n #{dimmed(type[:file_hint].call(relative))}" if type[:file_hint]
431
680
  end
681
+ end
682
+ end
432
683
 
433
- puts "\nResults saved to #{output_file}" unless no_log_file
684
+ def log_file_lists(results, log)
685
+ ISSUE_TYPES.each do |type|
686
+ file_list = results.send(type[:key])
687
+ next unless file_list.any?
434
688
 
435
- problem_files.any?
436
- ensure
437
- log.close unless no_log_file
689
+ log.puts "\n#{heading("Files: #{type[:label]}")}"
690
+ file_list.each { |file| log.puts file }
438
691
  end
439
692
  end
440
693
 
441
- private
694
+ def print_issue_summary(results)
695
+ counts = results.diagnostic_counts
696
+ return if counts.empty?
697
+
698
+ puts "\n"
699
+ puts " #{bold("Issue summary:")}"
700
+
701
+ counts.each do |name, data|
702
+ count_text = dimmed("(#{data[:count]} #{pluralize(data[:count], "error")} in #{data[:files].size} #{pluralize(data[:files].size, "file")})")
703
+ puts " #{white(name)} #{count_text}"
704
+ end
705
+ end
706
+
707
+ def reportable_files?(results)
708
+ ISSUE_TYPES.any? { |type| type[:reportable] && results.send(type[:key]).any? }
709
+ end
710
+
711
+ def print_reportable_files(results)
712
+ reportable_types = ISSUE_TYPES.select { |type| type[:reportable] }
713
+ reportable_files = reportable_types.flat_map { |type|
714
+ results.send(type[:key]).map { |file| [file, type] }
715
+ }
716
+ return if reportable_files.empty?
717
+
718
+ reportable_breakdown = reportable_types.filter_map { |type|
719
+ count = results.send(type[:key]).count
720
+ "#{count} #{type[:label].downcase}" if count.positive?
721
+ }
722
+
723
+ puts "\n"
724
+ puts " #{bold("Reportable issues:")}"
725
+ puts " #{dimmed("The following files likely failed due to issues in Herb, not in your templates.")}"
726
+ puts " #{dimmed("Reporting them helps improve Herb and makes it better for everyone.")}"
727
+ puts " #{dimmed("See the detailed output above for more information on why each file failed.")}"
728
+ puts ""
729
+ puts " #{dimmed("#{reportable_files.count} #{pluralize(reportable_files.count, "issue")} could be reported: #{reportable_breakdown.join(", ")}")}"
730
+ puts ""
731
+
732
+ reportable_files.each do |(file_path, _issue_type)|
733
+ puts " #{relative_path(file_path)}"
734
+ end
735
+
736
+ puts ""
737
+ puts " #{dimmed("Run `herb report <file>` to generate a copy-able report for filing an issue.")}"
738
+ puts " #{dimmed("Run `herb playground <file>` to visually inspect the parse result, see diagnostics, or check if it's already fixed on main.")}"
739
+
740
+ puts ""
741
+ puts " #{dimmed("https://github.com/marcoroth/herb/issues")}"
742
+ end
743
+
744
+ def log_problem_file_details(results, log)
745
+ return unless results.problem_files.any?
746
+
747
+ log.puts "\n#{heading("FILE CONTENTS AND DETAILS")}"
748
+
749
+ results.problem_files.each do |file|
750
+ next unless results.file_contents[file]
751
+
752
+ divider = "=" * [80, file.length].max
753
+
754
+ log.puts
755
+ log.puts divider
756
+ log.puts file
757
+ log.puts divider
758
+
759
+ log.puts "\n#{heading("CONTENT")}"
760
+ log.puts "```erb"
761
+ log.puts results.file_contents[file]
762
+ log.puts "```"
763
+
764
+ log_error_outputs(results.error_outputs[file], log)
765
+ log_parse_errors(results.parse_errors[file], log)
766
+ log_compilation_errors(results.compilation_errors[file], log)
767
+ end
768
+ end
769
+
770
+ def log_error_outputs(error_output, log)
771
+ return unless error_output
772
+
773
+ if error_output[:exit_code]
774
+ log.puts "\n#{heading("EXIT CODE")}"
775
+ log.puts error_output[:exit_code]
776
+ end
777
+
778
+ if error_output[:stderr].strip.length.positive?
779
+ log.puts "\n#{heading("ERROR OUTPUT")}"
780
+ log.puts "```"
781
+ log.puts error_output[:stderr]
782
+ log.puts "```"
783
+ end
784
+
785
+ return unless error_output[:stdout].strip.length.positive?
786
+
787
+ log.puts "\n#{heading("STANDARD OUTPUT")}"
788
+ log.puts "```"
789
+ log.puts error_output[:stdout]
790
+ log.puts "```"
791
+ log.puts
792
+ end
793
+
794
+ def log_parse_errors(parse_error, log)
795
+ return unless parse_error
796
+
797
+ if parse_error[:stdout].strip.length.positive?
798
+ log.puts "\n#{heading("STANDARD OUTPUT")}"
799
+ log.puts "```"
800
+ log.puts parse_error[:stdout]
801
+ log.puts "```"
802
+ end
803
+
804
+ if parse_error[:stderr].strip.length.positive?
805
+ log.puts "\n#{heading("ERROR OUTPUT")}"
806
+ log.puts "```"
807
+ log.puts parse_error[:stderr]
808
+ log.puts "```"
809
+ end
810
+
811
+ return unless parse_error[:ast]
812
+
813
+ log.puts "\n#{heading("AST")}"
814
+ log.puts "```"
815
+ log.puts parse_error[:ast]
816
+ log.puts "```"
817
+ log.puts
818
+ end
819
+
820
+ def log_compilation_errors(compilation_error, log)
821
+ return unless compilation_error
822
+
823
+ log.puts "\n#{heading("COMPILATION ERROR")}"
824
+ log.puts "```"
825
+ log.puts compilation_error[:error]
826
+ log.puts "```"
827
+
828
+ return unless compilation_error[:backtrace].any?
829
+
830
+ log.puts "\n#{heading("BACKTRACE")}"
831
+ log.puts "```"
832
+ log.puts compilation_error[:backtrace].join("\n")
833
+ log.puts "```"
834
+ log.puts
835
+ end
836
+
837
+ def label(text, width = 12)
838
+ dimmed(text.ljust(width))
839
+ end
442
840
 
443
- def progress_bar(current, total, width = (IO.console&.winsize&.[](1) || 80) - "[] 100% (#{total}/#{total})".length)
444
- progress = current.to_f / total
445
- completed_length = (progress * width).to_i
446
- completed = "█" * completed_length
841
+ def stat(count, text, color)
842
+ value = "#{count} #{text}"
447
843
 
448
- partial_index = ((progress * width) % 1 * 8).to_i
449
- partial_chars = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]
450
- partial = partial_index.zero? ? "" : partial_chars[partial_index]
844
+ if count.positive?
845
+ bold(send(color, value))
846
+ else
847
+ bold(green(value))
848
+ end
849
+ end
451
850
 
452
- remaining = " " * (width - completed_length - (partial.empty? ? 0 : 1))
453
- percentage = (progress * 100).to_i
851
+ def relative_path(absolute_path)
852
+ Pathname.new(absolute_path).relative_path_from(Pathname.pwd).to_s
853
+ end
454
854
 
455
- # Format as [███████▋ ] 42% (123/292)
456
- "[#{completed}#{partial}#{remaining}] #{percentage}% (#{current}/#{total})"
855
+ def pluralize(count, singular, plural = nil)
856
+ count == 1 ? singular : (plural || "#{singular}s")
457
857
  end
458
858
 
459
859
  def percentage(part, total)
@@ -462,6 +862,21 @@ module Herb
462
862
  ((part.to_f / total) * 100).round(1)
463
863
  end
464
864
 
865
+ def ensure_parallel!
866
+ return if defined?(Parallel)
867
+
868
+ require "bundler/inline"
869
+
870
+ gemfile(true, quiet: true) do
871
+ source "https://rubygems.org"
872
+ gem "parallel"
873
+ end
874
+ end
875
+
876
+ def separator
877
+ dimmed("─" * 60)
878
+ end
879
+
465
880
  def heading(text)
466
881
  prefix = "--- #{text.upcase} "
467
882
 
@@ -479,5 +894,182 @@ module Herb
479
894
  "#{minutes}m #{remaining_seconds.round(2)}s"
480
895
  end
481
896
  end
897
+
898
+ def capture_leak_check(file_content)
899
+ Herb.leak_check(file_content)
900
+ rescue StandardError
901
+ { lex: { allocations: 0, deallocations: 0, bytes_allocated: 0, bytes_deallocated: 0 },
902
+ parse: { allocations: 0, deallocations: 0, bytes_allocated: 0, bytes_deallocated: 0 },
903
+ extract_ruby: { allocations: 0, deallocations: 0, bytes_allocated: 0, bytes_deallocated: 0 },
904
+ extract_html: { allocations: 0, deallocations: 0, bytes_allocated: 0, bytes_deallocated: 0 } }
905
+ end
906
+
907
+ def print_leak_check_summary(file_results)
908
+ leaky_files = file_results.filter_map { |result|
909
+ next unless result[:leak_check]
910
+
911
+ ops = result[:leak_check]
912
+ leaks = ops.select { |_op, stats| stats[:leaks]&.any? || stats[:allocations] != stats[:deallocations] || stats[:untracked_deallocations]&.positive? }
913
+ next if leaks.empty?
914
+
915
+ { file: result[:file_path], leaks: leaks, all: ops }
916
+ }
917
+
918
+ puts "\n #{separator}"
919
+ puts "\n"
920
+ puts " #{bold("Leak check:")}"
921
+
922
+ if leaky_files.empty?
923
+ puts ""
924
+ puts " #{bold(green("✓"))} #{green("No leaks detected across all files.")}"
925
+ return
926
+ end
927
+
928
+ puts " #{red("#{leaky_files.size} #{pluralize(leaky_files.size, "file")} with potential leaks:")}"
929
+ puts ""
930
+
931
+ leaky_files.each do |entry|
932
+ relative = relative_path(entry[:file])
933
+ puts " #{cyan(relative)}:"
934
+
935
+ entry[:all].each do |op, stats|
936
+ leaks = stats[:leaks] || []
937
+ untracked_count = stats[:untracked_deallocations] || 0
938
+ untracked_ptrs = stats[:untracked_pointers] || []
939
+ leaked_bytes = stats[:bytes_allocated] - stats[:bytes_deallocated]
940
+
941
+ if leaks.any?
942
+ puts " #{red("✗")} #{op}: #{stats[:allocations]} allocs, #{stats[:deallocations]} deallocs (#{bold(red("#{leaks.size} unfreed, #{format_bytes(leaked_bytes)}"))})"
943
+ leaks.each_with_index do |size, i|
944
+ puts " #{dimmed("#{i + 1}.")} #{format_bytes(size)}"
945
+ end
946
+ elsif untracked_count.positive?
947
+ puts " #{yellow("~")} #{op}: #{stats[:allocations]} allocs, #{stats[:deallocations]} deallocs"
948
+ else
949
+ puts " #{green("✓")} #{op}: #{stats[:allocations]} allocs, #{stats[:deallocations]} deallocs"
950
+ end
951
+
952
+ next unless untracked_count.positive?
953
+
954
+ puts " #{yellow("#{untracked_count} untracked #{pluralize(untracked_count, "deallocation")}")} #{dimmed("(freed through allocator but not allocated through it)")}"
955
+ untracked_ptrs.each_with_index do |ptr, i|
956
+ puts " #{dimmed("#{i + 1}.")} #{ptr}"
957
+ end
958
+ end
959
+
960
+ puts ""
961
+ end
962
+
963
+ op_to_command = { lex: "lex", parse: "parse", extract_ruby: "ruby", extract_html: "html" }
964
+
965
+ commands = leaky_files.flat_map { |entry|
966
+ entry[:leaks].keys.map { |op| { command: op_to_command[op] || op.to_s, file: entry[:file] } }
967
+ }
968
+
969
+ puts " #{dimmed("To debug, run the following from the herb repo root (build with `make` first):")}"
970
+ puts ""
971
+ puts " #{dimmed("# macOS")}"
972
+ commands.each do |cmd|
973
+ puts " leaks --atExit -- ./herb #{cmd[:command]} #{cmd[:file]}"
974
+ end
975
+ puts ""
976
+ puts " #{dimmed("# Linux")}"
977
+ commands.each do |cmd|
978
+ puts " valgrind --leak-check=full ./herb #{cmd[:command]} #{cmd[:file]}"
979
+ end
980
+ end
981
+
982
+ def capture_arena_stats(file_content)
983
+ stats = Herb.arena_stats(file_content)
984
+
985
+ {
986
+ pages: stats[:pages],
987
+ bytes: stats[:total_used],
988
+ allocations: stats[:allocations],
989
+ lines: file_content.count("\n") + 1,
990
+ length: file_content.bytesize,
991
+ }
992
+ rescue StandardError
993
+ { pages: 0, bytes: 0, allocations: 0, lines: 0, length: 0 }
994
+ end
995
+
996
+ def print_arena_summary(file_results)
997
+ stats = file_results.filter_map { |result|
998
+ next unless result[:arena_stats] && result[:arena_stats][:bytes].positive?
999
+
1000
+ { file: result[:file_path], **result[:arena_stats] }
1001
+ }
1002
+
1003
+ return if stats.empty?
1004
+
1005
+ stats.sort_by! { |stat| -stat[:bytes] }
1006
+
1007
+ puts "\n #{separator}"
1008
+ puts "\n"
1009
+ puts " #{bold("Arena memory usage:")}"
1010
+ puts ""
1011
+
1012
+ relatives = stats.map { |stat| relative_path(stat[:file]) }
1013
+ used_strings = stats.map { |stat| format_bytes(stat[:bytes]) }
1014
+ length_strings = stats.map { |stat| format_bytes(stat[:length]) }
1015
+ used_width = [used_strings.max_by(&:length).length, 4].max
1016
+ pages_width = [stats.max_by { |stat| stat[:pages] }[:pages].to_s.length, 5].max
1017
+ allocs_width = [stats.max_by { |stat| stat[:allocations] }[:allocations].to_s.length, 6].max
1018
+ lines_width = [stats.max_by { |stat| stat[:lines] }[:lines].to_s.length, 5].max
1019
+ length_width = [length_strings.max_by(&:length).length, 4].max
1020
+ total_width = pages_width + used_width + allocs_width + lines_width + length_width + 11
1021
+
1022
+ puts format(" %#{lines_width}s %#{length_width}s %#{pages_width}s %#{used_width}s %#{allocs_width}s %s", "Lines", "Size", "Pages", "Used", "Allocs", "File")
1023
+ puts " #{"-" * (total_width + relatives.max_by(&:length).length)}"
1024
+
1025
+ stats.each_with_index do |stat, index|
1026
+ relative = relatives[index]
1027
+ used = used_strings[index]
1028
+ length = length_strings[index]
1029
+ color = stat[:pages] > 1 ? :yellow : :green
1030
+ colored_used = send(color, used)
1031
+ padding = colored_used.length - used.length
1032
+ puts format(" %#{lines_width}d %#{length_width}s %#{pages_width}d %#{used_width + padding}s %#{allocs_width}d %s", stat[:lines], length, stat[:pages], colored_used, stat[:allocations], relative)
1033
+ end
1034
+
1035
+ total_bytes = stats.sum { |stat| stat[:bytes] }
1036
+ max = stats.first
1037
+
1038
+ puts ""
1039
+ puts " #{label("Total")} #{cyan(format_bytes(total_bytes))} across #{cyan("#{stats.size} #{pluralize(stats.size, "file")}")}"
1040
+ puts " #{label("Largest")} #{cyan(relative_path(max[:file]))} (#{cyan(format_bytes(max[:bytes]))}, #{cyan("#{max[:pages]} #{pluralize(max[:pages], "page")}")})"
1041
+
1042
+ boundaries = [0, 16 * 1024, 64 * 1024, 128 * 1024, 256 * 1024, 512 * 1024]
1043
+
1044
+ total = stats.size
1045
+ puts ""
1046
+ bucket_counts = []
1047
+ boundaries.each_cons(2) do |low, high|
1048
+ count = stats.count { |stat| stat[:bytes] > low && stat[:bytes] <= high }
1049
+ low_label = format_bytes(low).rjust(6)
1050
+ high_label = format_bytes(high).rjust(6)
1051
+ bucket_counts << { label: " #{low_label} - #{high_label}", count: count }
1052
+ end
1053
+ last = boundaries.last
1054
+ count = stats.count { |stat| stat[:bytes] > last }
1055
+ bucket_counts << { label: " > #{format_bytes(last)}", count: count }
1056
+
1057
+ count_width = bucket_counts.max_by { |b| b[:count] }[:count].to_s.length
1058
+ pct_width = bucket_counts.map { |b| "#{percentage(b[:count], total)}%".length }.max
1059
+ bucket_counts.each do |bucket|
1060
+ pct = "#{percentage(bucket[:count], total)}%"
1061
+ puts " #{label(bucket[:label], 19)} #{bucket[:count].to_s.rjust(count_width)} #{pluralize(bucket[:count], "file").ljust(5)} #{pct.rjust(pct_width)}"
1062
+ end
1063
+ end
1064
+
1065
+ def format_bytes(bytes)
1066
+ if bytes >= 1024 * 1024
1067
+ "#{(bytes / (1024.0 * 1024.0)).round(1)} MB"
1068
+ elsif bytes >= 1024
1069
+ "#{(bytes / 1024.0).round(0)} KB"
1070
+ else
1071
+ "#{bytes} B"
1072
+ end
1073
+ end
482
1074
  end
483
1075
  end