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.
- checksums.yaml +4 -4
- data/Makefile +11 -3
- data/README.md +64 -34
- data/Rakefile +48 -40
- data/config.yml +317 -34
- data/ext/herb/error_helpers.c +367 -140
- data/ext/herb/error_helpers.h +1 -0
- data/ext/herb/extconf.rb +67 -28
- data/ext/herb/extension.c +317 -51
- data/ext/herb/extension.h +1 -0
- data/ext/herb/extension_helpers.c +23 -14
- data/ext/herb/extension_helpers.h +2 -2
- data/ext/herb/nodes.c +537 -270
- data/ext/herb/nodes.h +1 -0
- data/herb.gemspec +3 -2
- data/lib/herb/3.0/herb.so +0 -0
- data/lib/herb/3.1/herb.so +0 -0
- data/lib/herb/3.2/herb.so +0 -0
- data/lib/herb/3.3/herb.so +0 -0
- data/lib/herb/3.4/herb.so +0 -0
- data/lib/herb/4.0/herb.so +0 -0
- data/lib/herb/ast/helpers.rb +3 -3
- data/lib/herb/ast/node.rb +15 -2
- data/lib/herb/ast/nodes.rb +1132 -157
- data/lib/herb/bootstrap.rb +87 -0
- data/lib/herb/cli.rb +341 -31
- data/lib/herb/configuration.rb +248 -0
- data/lib/herb/defaults.yml +32 -0
- data/lib/herb/engine/compiler.rb +78 -11
- data/lib/herb/engine/debug_visitor.rb +13 -3
- data/lib/herb/engine/error_formatter.rb +13 -9
- data/lib/herb/engine/parser_error_overlay.rb +10 -6
- data/lib/herb/engine/validator.rb +8 -3
- data/lib/herb/engine/validators/nesting_validator.rb +2 -2
- data/lib/herb/engine.rb +82 -35
- data/lib/herb/errors.rb +563 -88
- data/lib/herb/lex_result.rb +1 -0
- data/lib/herb/location.rb +7 -3
- data/lib/herb/parse_result.rb +12 -2
- data/lib/herb/parser_options.rb +57 -0
- data/lib/herb/position.rb +1 -0
- data/lib/herb/prism_inspect.rb +116 -0
- data/lib/herb/project.rb +923 -331
- data/lib/herb/range.rb +1 -0
- data/lib/herb/token.rb +7 -1
- data/lib/herb/version.rb +1 -1
- data/lib/herb/visitor.rb +37 -2
- data/lib/herb/warnings.rb +6 -1
- data/lib/herb.rb +35 -3
- data/sig/herb/ast/helpers.rbs +2 -2
- data/sig/herb/ast/node.rbs +12 -2
- data/sig/herb/ast/nodes.rbs +641 -128
- data/sig/herb/bootstrap.rbs +31 -0
- data/sig/herb/configuration.rbs +89 -0
- data/sig/herb/engine/compiler.rbs +9 -1
- data/sig/herb/engine/debug_visitor.rbs +2 -0
- data/sig/herb/engine/validator.rbs +5 -1
- data/sig/herb/engine.rbs +17 -3
- data/sig/herb/errors.rbs +258 -63
- data/sig/herb/location.rbs +4 -0
- data/sig/herb/parse_result.rbs +4 -2
- data/sig/herb/parser_options.rbs +42 -0
- data/sig/herb/position.rbs +1 -0
- data/sig/herb/prism_inspect.rbs +28 -0
- data/sig/herb/range.rbs +1 -0
- data/sig/herb/token.rbs +6 -0
- data/sig/herb/visitor.rbs +25 -4
- data/sig/herb/warnings.rbs +6 -1
- data/sig/herb.rbs +14 -0
- data/sig/herb_c_extension.rbs +5 -2
- data/sig/serialized_ast_errors.rbs +54 -6
- data/sig/serialized_ast_nodes.rbs +60 -6
- data/src/analyze/action_view/attribute_extraction_helpers.c +290 -0
- data/src/analyze/action_view/content_tag.c +70 -0
- data/src/analyze/action_view/link_to.c +143 -0
- data/src/analyze/action_view/registry.c +60 -0
- data/src/analyze/action_view/tag.c +64 -0
- data/src/analyze/action_view/tag_helper_node_builders.c +305 -0
- data/src/analyze/action_view/tag_helpers.c +748 -0
- data/src/analyze/action_view/turbo_frame_tag.c +88 -0
- data/src/analyze/analyze.c +882 -0
- data/src/{analyzed_ruby.c → analyze/analyzed_ruby.c} +13 -11
- data/src/analyze/builders.c +343 -0
- data/src/analyze/conditional_elements.c +594 -0
- data/src/analyze/conditional_open_tags.c +640 -0
- data/src/analyze/control_type.c +250 -0
- data/src/{analyze_helpers.c → analyze/helpers.c} +48 -23
- data/src/analyze/invalid_structures.c +193 -0
- data/src/{analyze_missing_end.c → analyze/missing_end.c} +33 -22
- data/src/analyze/parse_errors.c +84 -0
- data/src/analyze/prism_annotate.c +397 -0
- data/src/{analyze_transform.c → analyze/transform.c} +17 -3
- data/src/ast_node.c +17 -7
- data/src/ast_nodes.c +662 -387
- data/src/ast_pretty_print.c +190 -6
- data/src/errors.c +1076 -520
- data/src/extract.c +145 -49
- data/src/herb.c +52 -34
- data/src/html_util.c +241 -12
- data/src/include/analyze/action_view/attribute_extraction_helpers.h +36 -0
- data/src/include/analyze/action_view/tag_helper_handler.h +41 -0
- data/src/include/analyze/action_view/tag_helper_node_builders.h +70 -0
- data/src/include/analyze/action_view/tag_helpers.h +38 -0
- data/src/include/{analyze.h → analyze/analyze.h} +14 -4
- data/src/include/{analyzed_ruby.h → analyze/analyzed_ruby.h} +3 -3
- data/src/include/analyze/builders.h +27 -0
- data/src/include/analyze/conditional_elements.h +9 -0
- data/src/include/analyze/conditional_open_tags.h +9 -0
- data/src/include/analyze/control_type.h +14 -0
- data/src/include/{analyze_helpers.h → analyze/helpers.h} +4 -2
- data/src/include/analyze/invalid_structures.h +11 -0
- data/src/include/analyze/prism_annotate.h +16 -0
- data/src/include/ast_node.h +11 -5
- data/src/include/ast_nodes.h +117 -38
- data/src/include/ast_pretty_print.h +5 -0
- data/src/include/element_source.h +3 -8
- data/src/include/errors.h +148 -55
- data/src/include/extract.h +21 -5
- data/src/include/herb.h +18 -6
- data/src/include/herb_prism_node.h +13 -0
- data/src/include/html_util.h +7 -2
- data/src/include/io.h +3 -1
- data/src/include/lex_helpers.h +29 -0
- data/src/include/lexer.h +1 -1
- data/src/include/lexer_peek_helpers.h +87 -13
- data/src/include/lexer_struct.h +2 -0
- data/src/include/location.h +2 -1
- data/src/include/parser.h +27 -2
- data/src/include/parser_helpers.h +19 -3
- data/src/include/pretty_print.h +10 -5
- data/src/include/prism_context.h +45 -0
- data/src/include/prism_helpers.h +10 -7
- data/src/include/prism_serialized.h +12 -0
- data/src/include/token.h +16 -4
- data/src/include/token_struct.h +10 -3
- data/src/include/utf8.h +2 -1
- data/src/include/util/hb_allocator.h +78 -0
- data/src/include/util/hb_arena.h +6 -1
- data/src/include/util/hb_arena_debug.h +12 -1
- data/src/include/util/hb_array.h +7 -3
- data/src/include/util/hb_buffer.h +6 -4
- data/src/include/util/hb_foreach.h +79 -0
- data/src/include/util/hb_narray.h +8 -4
- data/src/include/util/hb_string.h +56 -9
- data/src/include/util.h +6 -3
- data/src/include/version.h +1 -1
- data/src/io.c +3 -2
- data/src/lexer.c +42 -30
- data/src/lexer_peek_helpers.c +12 -74
- data/src/location.c +2 -2
- data/src/main.c +53 -28
- data/src/parser.c +783 -247
- data/src/parser_helpers.c +110 -23
- data/src/parser_match_tags.c +109 -48
- data/src/pretty_print.c +29 -24
- data/src/prism_helpers.c +30 -27
- data/src/ruby_parser.c +2 -0
- data/src/token.c +151 -66
- data/src/token_matchers.c +0 -1
- data/src/utf8.c +7 -6
- data/src/util/hb_allocator.c +341 -0
- data/src/util/hb_arena.c +81 -56
- data/src/util/hb_arena_debug.c +32 -17
- data/src/util/hb_array.c +30 -15
- data/src/util/hb_buffer.c +17 -21
- data/src/util/hb_narray.c +22 -7
- data/src/util/hb_string.c +49 -35
- data/src/util.c +21 -11
- data/src/visitor.c +47 -0
- data/templates/ext/herb/error_helpers.c.erb +24 -11
- data/templates/ext/herb/error_helpers.h.erb +1 -0
- data/templates/ext/herb/nodes.c.erb +50 -16
- data/templates/ext/herb/nodes.h.erb +1 -0
- data/templates/java/error_helpers.c.erb +1 -1
- data/templates/java/nodes.c.erb +30 -8
- data/templates/java/org/herb/ast/Errors.java.erb +24 -1
- data/templates/java/org/herb/ast/Nodes.java.erb +80 -21
- data/templates/javascript/packages/core/src/errors.ts.erb +16 -3
- data/templates/javascript/packages/core/src/node-type-guards.ts.erb +3 -1
- data/templates/javascript/packages/core/src/nodes.ts.erb +109 -32
- data/templates/javascript/packages/node/extension/error_helpers.cpp.erb +13 -4
- data/templates/javascript/packages/node/extension/nodes.cpp.erb +43 -4
- data/templates/lib/herb/ast/nodes.rb.erb +88 -31
- data/templates/lib/herb/errors.rb.erb +15 -3
- data/templates/lib/herb/visitor.rb.erb +2 -2
- data/templates/rust/src/ast/nodes.rs.erb +97 -44
- data/templates/rust/src/errors.rs.erb +2 -1
- data/templates/rust/src/nodes.rs.erb +167 -15
- data/templates/rust/src/union_types.rs.erb +60 -0
- data/templates/rust/src/visitor.rs.erb +81 -0
- data/templates/src/{analyze_missing_end.c.erb → analyze/missing_end.c.erb} +9 -6
- data/templates/src/{analyze_transform.c.erb → analyze/transform.c.erb} +2 -2
- data/templates/src/ast_nodes.c.erb +34 -26
- data/templates/src/ast_pretty_print.c.erb +24 -5
- data/templates/src/errors.c.erb +60 -54
- data/templates/src/include/ast_nodes.h.erb +6 -2
- data/templates/src/include/ast_pretty_print.h.erb +5 -0
- data/templates/src/include/errors.h.erb +15 -11
- data/templates/src/include/util/hb_foreach.h.erb +20 -0
- data/templates/src/parser_match_tags.c.erb +10 -4
- data/templates/src/visitor.c.erb +2 -2
- data/templates/template.rb +204 -29
- data/templates/wasm/error_helpers.cpp.erb +9 -5
- data/templates/wasm/nodes.cpp.erb +41 -4
- metadata +57 -16
- data/src/analyze.c +0 -1608
- data/src/element_source.c +0 -12
- data/src/include/util/hb_system.h +0 -9
- 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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
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
|
|
37
|
-
|
|
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 ||=
|
|
146
|
+
@files ||= file_paths || find_files
|
|
46
147
|
end
|
|
47
148
|
|
|
48
|
-
|
|
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 "
|
|
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
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
124
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
146
|
-
file_content = File.read(file_path)
|
|
225
|
+
ensure_parallel!
|
|
147
226
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
231
|
+
unless silent
|
|
232
|
+
puts "" unless verbose
|
|
233
|
+
puts ""
|
|
234
|
+
puts separator
|
|
235
|
+
end
|
|
156
236
|
|
|
157
|
-
|
|
158
|
-
|
|
237
|
+
file_results.each do |result|
|
|
238
|
+
merge_file_result(result, results, log)
|
|
239
|
+
end
|
|
159
240
|
|
|
160
|
-
|
|
161
|
-
File.open(ast_file.path, "w") do |f|
|
|
162
|
-
f.puts result.value.inspect
|
|
163
|
-
end
|
|
241
|
+
log.puts ""
|
|
164
242
|
|
|
165
|
-
|
|
166
|
-
end
|
|
243
|
+
duration = no_timing ? nil : Time.now - start_time
|
|
167
244
|
|
|
168
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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
|
-
|
|
259
|
+
if arena_stats
|
|
260
|
+
print_arena_summary(file_results)
|
|
261
|
+
end
|
|
266
262
|
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
276
|
-
|
|
277
|
-
puts "
|
|
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
|
-
|
|
272
|
+
puts "\n #{separator}"
|
|
273
|
+
print_summary(results, log, duration)
|
|
284
274
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
281
|
+
def print_file_report(file_path)
|
|
282
|
+
file_path = File.expand_path(file_path)
|
|
283
|
+
results = @results
|
|
305
284
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
318
|
+
if file_content
|
|
319
|
+
puts ""
|
|
320
|
+
puts "**Template:**"
|
|
321
|
+
puts "```erb"
|
|
322
|
+
puts file_content
|
|
323
|
+
puts "```"
|
|
324
|
+
end
|
|
315
325
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
puts "\nFiles that timed out:"
|
|
389
|
+
stdout_file = Tempfile.new("stdout")
|
|
390
|
+
stderr_file = Tempfile.new("stderr")
|
|
325
391
|
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
333
|
-
log.puts "\n#{heading("Files with compilation errors")}"
|
|
334
|
-
puts "\nFiles with compilation errors:"
|
|
407
|
+
Process.waitpid(pid)
|
|
335
408
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
427
|
+
result
|
|
428
|
+
rescue Timeout::Error
|
|
429
|
+
begin
|
|
430
|
+
Process.kill("TERM", pid)
|
|
431
|
+
rescue StandardError
|
|
432
|
+
nil
|
|
433
|
+
end
|
|
343
434
|
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
450
|
+
tempfile.close
|
|
451
|
+
tempfile.unlink
|
|
452
|
+
end
|
|
453
|
+
end
|
|
351
454
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
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
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
674
|
+
else
|
|
675
|
+
severity = send(type[:color], type[:symbol])
|
|
676
|
+
puts " #{severity} #{type[:label]}"
|
|
422
677
|
end
|
|
423
|
-
end
|
|
424
678
|
|
|
425
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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
|
-
|
|
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
|
|
444
|
-
|
|
445
|
-
completed_length = (progress * width).to_i
|
|
446
|
-
completed = "█" * completed_length
|
|
841
|
+
def stat(count, text, color)
|
|
842
|
+
value = "#{count} #{text}"
|
|
447
843
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
844
|
+
if count.positive?
|
|
845
|
+
bold(send(color, value))
|
|
846
|
+
else
|
|
847
|
+
bold(green(value))
|
|
848
|
+
end
|
|
849
|
+
end
|
|
451
850
|
|
|
452
|
-
|
|
453
|
-
|
|
851
|
+
def relative_path(absolute_path)
|
|
852
|
+
Pathname.new(absolute_path).relative_path_from(Pathname.pwd).to_s
|
|
853
|
+
end
|
|
454
854
|
|
|
455
|
-
|
|
456
|
-
|
|
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
|