From 1e9757e1ea64344985ea62822d715f3e118c4678 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Sat, 28 Feb 2026 06:07:14 +0100 Subject: [PATCH] Ruby CLI: Improve and flesh out `herb analyze` command --- Steepfile | 1 + lib/herb/cli.rb | 82 +++- lib/herb/engine.rb | 15 + lib/herb/project.rb | 1028 +++++++++++++++++++++++++++++-------------- sig/herb/engine.rbs | 3 + 5 files changed, 793 insertions(+), 336 deletions(-) diff --git a/Steepfile b/Steepfile index 5f4bdfa6d..c37030ed3 100644 --- a/Steepfile +++ b/Steepfile @@ -11,6 +11,7 @@ target :lib do library "pathname" library "tempfile" library "yaml" + library "prism" ignore "lib/herb/cli.rb" ignore "lib/herb/project.rb" diff --git a/lib/herb/cli.rb b/lib/herb/cli.rb index d5357cf7e..10f74b544 100644 --- a/lib/herb/cli.rb +++ b/lib/herb/cli.rb @@ -8,7 +8,7 @@ class Herb::CLI include Herb::Colors - attr_accessor :json, :silent, :no_interactive, :no_log_file, :no_timing, :local, :escape, :no_escape, :freeze, :debug, :tool, :strict, :analyze, :track_whitespace + attr_accessor :json, :silent, :log_file, :no_timing, :local, :escape, :no_escape, :freeze, :debug, :tool, :strict, :analyze, :track_whitespace, :verbose, :isolate def initialize(args) @args = args @@ -100,6 +100,7 @@ def help(exit_code = 0) bundle exec herb compile [file] Compile ERB template to Ruby code. bundle exec herb render [file] Compile and render ERB template to final output. bundle exec herb analyze [path] Analyze a project by passing a directory to the root of the project + bundle exec herb report [file] Generate a Markdown bug report for a file bundle exec herb config [path] Show configuration and file patterns for a project bundle exec herb ruby [file] Extract Ruby from a file. bundle exec herb html [file] Extract HTML from a file. @@ -133,13 +134,31 @@ def help(exit_code = 0) def result @result ||= case @command when "analyze" - project = Herb::Project.new(directory) - project.no_interactive = no_interactive - project.no_log_file = no_log_file + path = @file || "." + + if path != "-" && File.file?(path) + project = Herb::Project.new(File.dirname(path)) + project.file_paths = [File.expand_path(path)] + else + unless File.directory?(path) + puts "Not a file or directory: '#{path}'." + exit(1) + end + + project = Herb::Project.new(path) + end + + project.no_log_file = log_file ? false : true project.no_timing = no_timing project.silent = silent - has_issues = project.parse! + project.verbose = verbose || ci? + project.isolate = isolate + project.validate_ruby = true + has_issues = project.analyze! exit(has_issues ? 1 : 0) + when "report" + generate_report + exit(0) when "config" show_config exit(0) @@ -158,7 +177,12 @@ def result puts Herb.extract_html(file_content) exit(0) when "playground" - require "lz_string" + require "bundler/inline" + + gemfile do + source "https://rubygems.org" + gem "lz_string" + end hash = LZString::UriSafe.compress(file_content) local_url = "http://localhost:5173" @@ -221,12 +245,16 @@ def option_parser self.silent = true end - parser.on("-n", "--non-interactive", "Disable interactive output (progress bars, terminal clearing)") do - self.no_interactive = true + parser.on("--verbose", "Show detailed per-file progress (default in CI)") do + self.verbose = true end - parser.on("--no-log-file", "Disable log file generation") do - self.no_log_file = true + parser.on("--isolate", "Fork each file into its own process for crash isolation (slower)") do + self.isolate = true + end + + parser.on("--log-file", "Enable log file generation") do + self.log_file = true end parser.on("--no-timing", "Disable timing output") do @@ -287,6 +315,10 @@ def options private + def ci? + ENV["CI"] == "true" || ENV.key?("GITHUB_ACTIONS") || ENV.key?("BUILDKITE") || ENV.key?("JENKINS_URL") || ENV.key?("CIRCLECI") || ENV.key?("TRAVIS") + end + def find_node_binary(name) local_bin = File.join(Dir.pwd, "node_modules", ".bin", name) return local_bin if File.executable?(local_bin) @@ -361,6 +393,35 @@ def format_location_for_copy(location) end end + def generate_report + unless @file + puts "Usage: herb report " + exit(1) + end + + unless File.file?(@file) + puts "File not found: #{@file}" + exit(1) + end + + project = Herb::Project.new(File.dirname(@file)) + project.file_paths = [File.expand_path(@file)] + project.no_log_file = true + project.no_timing = true + project.silent = true + project.validate_ruby = true + + original_stdout = $stdout + $stdout = StringIO.new + begin + project.analyze! + ensure + $stdout = original_stdout + end + + project.print_file_report(@file) + end + def compile_template require_relative "engine" @@ -376,6 +437,7 @@ def compile_template options[:debug_filename] = @file if @file end + options[:validate_ruby] = true engine = Herb::Engine.new(file_content, options) if json diff --git a/lib/herb/engine.rb b/lib/herb/engine.rb index 20ee95d27..92b7fdcce 100644 --- a/lib/herb/engine.rb +++ b/lib/herb/engine.rb @@ -30,6 +30,9 @@ class Engine class CompilationError < StandardError end + class InvalidRubyError < CompilationError + end + def initialize(input, properties = {}) @filename = properties[:filename] ? ::Pathname.new(properties[:filename]) : nil @project_path = ::Pathname.new(properties[:project_path] || Dir.pwd) @@ -135,6 +138,18 @@ def initialize(input, properties = {}) @src << "; ensure\n #{@bufvar} = __original_outvar\nend\n" if properties[:ensure] + if properties.fetch(:validate_ruby, false) + require "prism" + + prism_result = Prism.parse(@src) + syntax_errors = prism_result.errors.reject { |e| e.type == :invalid_yield } + + if syntax_errors.any? + details = syntax_errors.map { |e| " - #{e.message} (line #{e.location.start_line})" }.join("\n") + raise InvalidRubyError, "Compiled template produced invalid Ruby:\n#{details}" + end + end + @src.freeze freeze end diff --git a/lib/herb/project.rb b/lib/herb/project.rb index 14c95f7c6..2d15c4652 100644 --- a/lib/herb/project.rb +++ b/lib/herb/project.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true # typed: ignore - # rbs_inline: disabled -require "io/console" require "timeout" require "tempfile" require "pathname" @@ -12,12 +10,111 @@ module Herb class Project - attr_accessor :project_path, :output_file, :no_interactive, :no_log_file, :no_timing, :silent + include Colors + + attr_accessor :project_path, :output_file, :no_log_file, :no_timing, :silent, :verbose, :isolate, :validate_ruby, :file_paths + + # Known error types that indicate issues in the user's template, not bugs in the parser. + TEMPLATE_ERRORS = [ + "MissingOpeningTagError", + "MissingClosingTagError", + "TagNamesMismatchError", + "VoidElementClosingTagError", + "UnclosedElementError", + "RubyParseError", + "ERBControlFlowScopeError", + "MissingERBEndTagError", + "ERBMultipleBlocksInTagError", + "ERBCaseWithConditionsError", + "ConditionalElementMultipleTagsError", + "ConditionalElementConditionMismatchError", + "InvalidCommentClosingTagError", + "OmittedClosingTagError", + "UnclosedOpenTagError", + "UnclosedCloseTagError", + "UnclosedQuoteError", + "MissingAttributeValueError", + "UnclosedERBTagError", + "StrayERBClosingTagError", + "NestedERBTagError" + ].freeze + + ISSUE_TYPES = [ + { key: :failed, label: "Parser crashed", symbol: "✗", color: :red, reportable: true, + hint: "This could be a bug in the parser. Reporting it helps us improve Herb for everyone.", + file_hint: ->(relative) { "Run `herb parse #{relative}` to see the parser output." } }, + { key: :template_error, label: "Template errors", symbol: "✗", color: :red, + hint: "These files have issues in the template. Review the errors and update your templates to fix them." }, + { key: :unexpected_error, label: "Unexpected parse errors", symbol: "✗", color: :red, reportable: true, + hint: "These errors may indicate a bug in the parser. Reporting them helps us make Herb more robust.", + file_hint: ->(relative) { "Run `herb parse #{relative}` to see the parser output." } }, + { key: :strict_parse_error, label: "Strict mode parse errors", symbol: "⚠", color: :yellow, + hint: "These files use HTML patterns like omitted closing tags. Add explicit closing tags to fix." }, + { key: :analyze_parse_error, label: "Analyze parse errors", symbol: "⚠", color: :yellow, + hint: "These files have issues detected during analysis. Review the errors and update your templates." }, + { key: :timeout, label: "Timed out", symbol: "⚠", color: :yellow, reportable: true, + hint: "These files took too long to parse. This could indicate a parser issue. Reporting it helps us track down edge cases." }, + { key: :validation_error, label: "Validation errors", symbol: "⚠", color: :yellow, + hint: "These templates have security, nesting, or accessibility issues. The templates compile fine otherwise. Review and fix these to improve your template structure." }, + { key: :compilation_failed, label: "Compilation errors", symbol: "✗", color: :red, reportable: true, + 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.", + file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compilation error." } }, + { key: :strict_compilation_failed, label: "Strict mode compilation errors", symbol: "⚠", color: :yellow, + hint: "These files fail to compile only in strict mode. Add explicit closing tags to fix, or pass --no-strict to allow.", + file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compilation error." } }, + { key: :invalid_ruby, label: "Invalid Ruby output", symbol: "✗", color: :red, reportable: true, + 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.", + file_hint: ->(relative) { "Run `herb compile #{relative}` to see the compiled output." } } + ].freeze + + class ResultTracker + attr_reader :successful, :failed, :timeout, :template_error, :unexpected_error, + :strict_parse_error, :analyze_parse_error, + :validation_error, :compilation_failed, :strict_compilation_failed, + :invalid_ruby, + :error_outputs, :file_contents, :parse_errors, :compilation_errors, + :file_diagnostics + + def initialize + @successful = [] + @failed = [] + @timeout = [] + @template_error = [] + @unexpected_error = [] + @strict_parse_error = [] + @analyze_parse_error = [] + @validation_error = [] + @compilation_failed = [] + @strict_compilation_failed = [] + @invalid_ruby = [] + @error_outputs = {} + @file_contents = {} + @parse_errors = {} + @compilation_errors = {} + @file_diagnostics = {} + end + + def problem_files + failed + timeout + template_error + unexpected_error + strict_parse_error + analyze_parse_error + + validation_error + compilation_failed + strict_compilation_failed + invalid_ruby + end + + def file_issue_type(file) + ISSUE_TYPES.find { |type| send(type[:key]).include?(file) } + end - def interactive? - return false if no_interactive + def diagnostic_counts + counts = Hash.new { |hash, key| hash[key] = { count: 0, files: Set.new } } - !IO.console.nil? + file_diagnostics.each do |file, diagnostics| + diagnostics.each do |diagnostic| + counts[diagnostic[:name]][:count] += 1 + counts[diagnostic[:name]][:files] << file + end + end + + counts.sort_by { |_name, value| -value[:count] } + end end def initialize(project_path, output_file: nil) @@ -46,7 +143,7 @@ def absolute_path end def files - @files ||= find_files + @files ||= file_paths || find_files end private @@ -66,7 +163,7 @@ def find_files public - def parse! + def analyze! start_time = Time.now unless no_timing log = if no_log_file @@ -95,388 +192,652 @@ def parse! return end - print "\e[H\e[2J" if interactive? - - successful_files = [] - failed_files = [] - timeout_files = [] - error_files = [] - compilation_failed_files = [] - error_outputs = {} - file_contents = {} - parse_errors = {} - compilation_errors = {} - - files.each_with_index do |file_path, index| - total_failed = failed_files.count - total_timeout = timeout_files.count - total_errors = error_files.count - total_compilation_failed = compilation_failed_files.count - - lines_to_clear = 6 + total_failed + total_timeout + total_errors + total_compilation_failed - lines_to_clear += 3 if total_failed.positive? - lines_to_clear += 3 if total_timeout.positive? - lines_to_clear += 3 if total_errors.positive? - lines_to_clear += 3 if total_compilation_failed.positive? - - lines_to_clear.times { print "\e[1A\e[K" } if index.positive? && interactive? - - if interactive? - puts "Parsing .html.erb files in: #{project_path}" - puts "Total files to process: #{files.count}\n" - - relative_path = file_path.sub("#{project_path}/", "") - - puts - puts progress_bar(index + 1, files.count) - puts + @results = ResultTracker.new + results = @results + + unless silent + puts "" + puts "#{bold("Herb")} 🌿 #{dimmed("v#{Herb::VERSION}")}" + puts "" + + if configuration.config_path + puts "#{green("✓")} Using Herb config file at #{dimmed(configuration.config_path)}" else - relative_path = file_path.sub("#{project_path}/", "") + puts dimmed("No .herb.yml found, using defaults") end - puts "Processing [#{index + 1}/#{files.count}]: #{relative_path}" unless silent - - if interactive? - if failed_files.any? - puts - puts "Files that failed:" - failed_files.each { |file| puts " - #{file}" } - puts - end - if timeout_files.any? - puts - puts "Files that timed out:" - timeout_files.each { |file| puts " - #{file}" } - puts - end + puts dimmed("Analyzing #{files.count} #{pluralize(files.count, "file")}...") + end - if error_files.any? - puts - puts "Files with parse errors:" - error_files.each { |file| puts " - #{file}" } - puts - end + total_width = files.count.to_s.length - if compilation_failed_files.any? - puts - puts "Files with compilation errors:" - compilation_failed_files.each { |file| puts " - #{file}" } - puts - end + finish_hook = lambda do |item, index, _file_result| + next if silent + + if verbose + relative_path = relative_path(item) + puts " #{dimmed("[#{(index + 1).to_s.rjust(total_width)}/#{files.count}]")} #{relative_path}" + else + print "." end + end - begin - file_content = File.read(file_path) + ensure_parallel! - stdout_file = Tempfile.new("stdout") - stderr_file = Tempfile.new("stderr") - ast_file = Tempfile.new("ast") + file_results = Parallel.map(files, in_processes: Parallel.processor_count, finish: finish_hook) do |file_path| + process_file(file_path) + end - Timeout.timeout(1) do - pid = Process.fork do - $stdout.reopen(stdout_file.path, "w") - $stderr.reopen(stderr_file.path, "w") + unless silent + puts "" unless verbose + puts "" + puts separator + end - begin - result = Herb.parse(file_content) + file_results.each do |result| + merge_file_result(result, results, log) + end - if result.failed? - File.open(ast_file.path, "w") do |f| - f.puts result.value.inspect - end + log.puts "" - exit!(2) - end + duration = no_timing ? nil : Time.now - start_time - exit!(0) - rescue StandardError => e - warn "Ruby exception: #{e.class}: #{e.message}" - warn e.backtrace.join("\n") if e.backtrace - exit!(1) - end - end + print_file_lists(results, log) - Process.waitpid(pid) - - stdout_file.rewind - stderr_file.rewind - stdout_content = stdout_file.read - stderr_content = stderr_file.read - ast = File.exist?(ast_file.path) ? File.read(ast_file.path) : "" - - case $CHILD_STATUS.exitstatus - when 0 - log.puts "✅ Parsed #{file_path} successfully" - - begin - Herb::Engine.new(file_content, filename: file_path, escape: true) - - log.puts "✅ Compiled #{file_path} successfully" - successful_files << file_path - rescue Herb::Engine::CompilationError => e - log.puts "❌ Compilation failed for #{file_path}" - - compilation_failed_files << file_path - compilation_errors[file_path] = { - error: e.message, - backtrace: e.backtrace&.first(10) || [], - } - - file_contents[file_path] = file_content - rescue StandardError => e - log.puts "❌ Unexpected compilation error for #{file_path}: #{e.class}: #{e.message}" - - compilation_failed_files << file_path - compilation_errors[file_path] = { - error: "#{e.class}: #{e.message}", - backtrace: e.backtrace&.first(10) || [], - } - - file_contents[file_path] = file_content - end - when 2 - message = "⚠️ Parsing #{file_path} completed with errors" - log.puts message - - parse_errors[file_path] = { - ast: ast, - stdout: stdout_content, - stderr: stderr_content, - } - - file_contents[file_path] = file_content - - error_files << file_path - else - message = "❌ Parsing #{file_path} failed" - log.puts message - - error_outputs[file_path] = { - exit_code: $CHILD_STATUS.exitstatus, - stdout: stdout_content, - stderr: stderr_content, - } - - file_contents[file_path] = file_content - - failed_files << file_path - end - end + if results.problem_files.any? + puts "\n #{separator}" + print_issue_summary(results) - stdout_file.close - stdout_file.unlink - stderr_file.close - stderr_file.unlink - ast_file.close - ast_file.unlink - rescue Timeout::Error - message = "⏱️ Parsing #{file_path} timed out after 1 second" - log.puts message - - begin - Process.kill("TERM", pid) - rescue StandardError - nil - end + if reportable_files?(results) + puts "\n #{separator}" + print_reportable_files(results) + end + end - timeout_files << file_path - file_contents[file_path] = file_content - rescue StandardError => e - message = "⚠️ Error processing #{file_path}: #{e.message}" - log.puts message + log_problem_file_details(results, log) - failed_files << file_path + unless no_log_file + puts "\n #{separator}" + puts "\n #{dimmed("Results saved to #{output_file}")}" + end - begin - file_contents[file_path] = File.read(file_path) - rescue StandardError => read_error - log.puts " Could not read file content: #{read_error.message}" - end + puts "\n #{separator}" + print_summary(results, log, duration) + + results.problem_files.any? + ensure + log.close unless no_log_file + end + end + + def print_file_report(file_path) + file_path = File.expand_path(file_path) + results = @results + + unless results + puts "No results available. Run parse! first." + return + end + + relative = relative_path(file_path) + issue_type = results.file_issue_type(file_path) + + unless issue_type + puts "No issues found for #{relative}." + return + end + + diagnostics = results.file_diagnostics[file_path] + file_content = results.file_contents[file_path] + + puts "- **Herb:** `#{Herb.version}`" + puts "- **Ruby:** `#{RUBY_VERSION}`" + puts "- **Platform:** `#{RUBY_PLATFORM}`" + puts "- **Category:** `#{issue_type[:label]}`" + + if diagnostics&.any? + puts "" + puts "**Errors:**" + diagnostics.each do |diagnostic| + lines = diagnostic[:message].split("\n") + puts "- **#{diagnostic[:name]}** #{lines.first}" + lines.drop(1).each do |line| + puts " #{line}" end end + end + + if file_content + puts "" + puts "**Template:**" + puts "```erb" + puts file_content + puts "```" + end + + return unless issue_type[:key] == :invalid_ruby && file_content + + begin + engine = Herb::Engine.new(file_content, filename: file_path, escape: true, validation_mode: :none) + puts "" + puts "**Compiled Ruby:**" + puts "```ruby" + puts engine.src + puts "```" + rescue StandardError + # Skip if compilation fails entirely + end + end + + private + + def process_file(file_path) + isolate ? process_file_isolated(file_path) : process_file_direct(file_path) + end - if interactive? - print "\e[1A\e[K" - puts "Completed processing all files." - print "\e[H\e[2J" + def process_file_direct(file_path) + file_content = File.read(file_path) + result = { file_path: file_path } + + Timeout.timeout(1) do + parse_result = Herb.parse(file_content) + + if parse_result.failed? + result[:file_content] = file_content + result.merge!(classify_parse_errors(file_path, file_content)) else - puts "Completed processing all files." unless silent + result[:log] = "✅ Parsed #{file_path} successfully" + result.merge!(compile_file(file_path, file_content)) end + end - log.puts "" + result + rescue Timeout::Error + { file_path: file_path, status: :timeout, file_content: file_content, + log: "⏱️ Parsing #{file_path} timed out after 1 second" } + rescue StandardError => e + file_content ||= begin + File.read(file_path) + rescue StandardError + nil + end - summary = [ - heading("Summary"), - "Total files: #{files.count}", - "✅ Successful (parsed & compiled): #{successful_files.count} (#{percentage(successful_files.count, - files.count)}%)", - "❌ Compilation errors: #{compilation_failed_files.count} (#{percentage(compilation_failed_files.count, - files.count)}%)", - "❌ Failed to parse: #{failed_files.count} (#{percentage(failed_files.count, files.count)}%)", - "⚠️ Parse errors: #{error_files.count} (#{percentage(error_files.count, files.count)}%)", - "⏱️ Timed out: #{timeout_files.count} (#{percentage(timeout_files.count, files.count)}%)" - ] - - summary.each do |line| - log.puts line - puts line - end + { file_path: file_path, status: :failed, file_content: file_content, + log: "⚠️ Error processing #{file_path}: #{e.message}" } + end - if failed_files.any? - log.puts "\n#{heading("Files that failed")}" - puts "\nFiles that failed:" + def process_file_isolated(file_path) + file_content = File.read(file_path) + result = { file_path: file_path } - failed_files.each do |f| - log.puts "- #{f}" - puts " - #{f}" - end - end + stdout_file = Tempfile.new("stdout") + stderr_file = Tempfile.new("stderr") - if error_files.any? - log.puts "\n#{heading("Files with parse errors")}" - puts "\nFiles with parse errors:" + Timeout.timeout(1) do + pid = Process.fork do + $stdout.reopen(stdout_file.path, "w") + $stderr.reopen(stderr_file.path, "w") - error_files.each do |f| - log.puts f - puts " - #{f}" + begin + parse_result = Herb.parse(file_content) + exit!(parse_result.failed? ? 2 : 0) + rescue StandardError => e + warn "Ruby exception: #{e.class}: #{e.message}" + warn e.backtrace.join("\n") if e.backtrace + exit!(1) end end - if timeout_files.any? - log.puts "\n#{heading("Files that timed out")}" - puts "\nFiles that timed out:" + Process.waitpid(pid) - timeout_files.each do |f| - log.puts f - puts " - #{f}" - end + stderr_file.rewind + stderr_content = stderr_file.read + + case $CHILD_STATUS.exitstatus + when 0 + result[:log] = "✅ Parsed #{file_path} successfully" + result.merge!(compile_file(file_path, file_content)) + when 2 + result[:file_content] = file_content + result.merge!(classify_parse_errors(file_path, file_content)) + else + result[:log] = "❌ Parsing #{file_path} failed" + result[:status] = :failed + result[:file_content] = file_content + result[:error_output] = { exit_code: $CHILD_STATUS.exitstatus, stderr: stderr_content } end + end - if compilation_failed_files.any? - log.puts "\n#{heading("Files with compilation errors")}" - puts "\nFiles with compilation errors:" + result + rescue Timeout::Error + begin + Process.kill("TERM", pid) + rescue StandardError + nil + end - compilation_failed_files.each do |f| - log.puts f - puts " - #{f}" - end - end + { file_path: file_path, status: :timeout, file_content: file_content, + log: "⏱️ Parsing #{file_path} timed out after 1 second" } + rescue StandardError => e + file_content ||= begin + File.read(file_path) + rescue StandardError + nil + end - problem_files = failed_files + timeout_files + error_files + compilation_failed_files + { file_path: file_path, status: :failed, file_content: file_content, + log: "⚠️ Error processing #{file_path}: #{e.message}" } + ensure + [stdout_file, stderr_file].each do |tempfile| + next unless tempfile - if problem_files.any? - log.puts "\n#{heading("FILE CONTENTS AND DETAILS")}" + tempfile.close + tempfile.unlink + end + end - problem_files.each do |file| - next unless file_contents[file] + def classify_parse_errors(file_path, file_content) + default_result = Herb.parse(file_content) + + diagnostics = if default_result.respond_to?(:errors) && default_result.errors.any? + default_result.errors.map do |error| + diagnostic = { name: error.error_name, message: error.message } + if error.respond_to?(:location) && error.location + diagnostic[:line] = error.location.start.line + diagnostic[:column] = error.location.start.column + end + diagnostic + end + end - divider = "=" * [80, file.length].max + no_strict_result = Herb.parse(file_content, strict: false) + no_analyze_result = Herb.parse(file_content, analyze: false) + + if no_strict_result.success? + { status: :strict_parse_error, diagnostics: diagnostics, + log: "⚠️ Parsing #{file_path} completed with strict mode errors" } + elsif no_analyze_result.success? + { status: :analyze_parse_error, diagnostics: diagnostics, + log: "⚠️ Parsing #{file_path} completed with analyze errors" } + elsif diagnostics&.any? && diagnostics.all? { |diagnostic| TEMPLATE_ERRORS.include?(diagnostic[:name]) } + { status: :template_error, diagnostics: diagnostics, + log: "⚠️ Parsing #{file_path} completed with template errors" } + else + { status: :unexpected_error, diagnostics: diagnostics, + log: "❌ Parsing #{file_path} completed with unexpected errors" } + end + end - log.puts - log.puts divider - log.puts file - log.puts divider + def compile_file(file_path, file_content) + Herb::Engine.new(file_content, filename: file_path, escape: true, validate_ruby: validate_ruby) - log.puts "\n#{heading("CONTENT")}" - log.puts "```erb" - log.puts file_contents[file] - log.puts "```" + { status: :successful, log: "✅ Compiled #{file_path} successfully" } + rescue Herb::Engine::InvalidRubyError => e + { status: :invalid_ruby, file_content: file_content, + compilation_error: { error: e.message, backtrace: e.backtrace&.first(10) || [] }, + diagnostics: [{ name: "InvalidRubyError", message: e.message }], + log: "🚨 Compiled Ruby is invalid for #{file_path}" } + rescue Herb::Engine::SecurityError, Herb::Engine::CompilationError => e + compilation_error = { error: e.message, backtrace: e.backtrace&.first(10) || [] } - if error_outputs[file] - if error_outputs[file][:exit_code] - log.puts "\n#{heading("EXIT CODE")}" - log.puts error_outputs[file][:exit_code] - end + # Retry without validators + begin + Herb::Engine.new(file_content, filename: file_path, escape: true, validation_mode: :none, validate_ruby: validate_ruby) + error_name = e.is_a?(Herb::Engine::SecurityError) ? "SecurityError" : "ValidationError" + return { status: :validation_error, file_content: file_content, + compilation_error: compilation_error, + diagnostics: [{ name: error_name, message: e.message }], + log: "⚠️ Compilation failed for #{file_path} (validation error)" } + rescue StandardError + # Not a validator-caused error, continue with other checks + end - if error_outputs[file][:stderr].strip.length.positive? - log.puts "\n#{heading("ERROR OUTPUT")}" - log.puts "```" - log.puts error_outputs[file][:stderr] - log.puts "```" - end + # Retry without strict mode + begin + Herb::Engine.new(file_content, filename: file_path, escape: true, strict: false, validate_ruby: validate_ruby) + return { status: :strict_compilation_failed, file_content: file_content, + compilation_error: compilation_error, + diagnostics: [{ name: "CompilationError", message: "#{e.message} (strict mode)" }], + log: "🔒 Compilation failed for #{file_path} (strict mode error)" } + rescue StandardError + # Fall through + end - if error_outputs[file][:stdout].strip.length.positive? - log.puts "\n#{heading("STANDARD OUTPUT")}" - log.puts "```" - log.puts error_outputs[file][:stdout] - log.puts "```" - log.puts - end - end + { status: :compilation_failed, file_content: file_content, + compilation_error: compilation_error, + diagnostics: [{ name: "CompilationError", message: e.message }], + log: "❌ Compilation failed for #{file_path}" } + rescue StandardError => e + { status: :compilation_failed, file_content: file_content, + compilation_error: { error: "#{e.class}: #{e.message}", backtrace: e.backtrace&.first(10) || [] }, + diagnostics: [{ name: e.class.to_s, message: e.message }], + log: "❌ Unexpected compilation error for #{file_path}: #{e.class}: #{e.message}" } + end - if parse_errors[file] - if parse_errors[file][:stdout].strip.length.positive? - log.puts "\n#{heading("STANDARD OUTPUT")}" - log.puts "```" - log.puts parse_errors[file][:stdout] - log.puts "```" - end + def merge_file_result(result, tracker, log) + file_path = result[:file_path] + status = result[:status] - if parse_errors[file][:stderr].strip.length.positive? - log.puts "\n#{heading("ERROR OUTPUT")}" - log.puts "```" - log.puts parse_errors[file][:stderr] - log.puts "```" - end + log.puts result[:log] if result[:log] - if parse_errors[file][:ast] - log.puts "\n#{heading("AST")}" - log.puts "```" - log.puts parse_errors[file][:ast] - log.puts "```" - log.puts - end - end + return unless status + + tracker.send(status) << file_path + + tracker.file_contents[file_path] = result[:file_content] if result[:file_content] + tracker.error_outputs[file_path] = result[:error_output] if result[:error_output] + tracker.parse_errors[file_path] = result[:parse_error] if result[:parse_error] + tracker.compilation_errors[file_path] = result[:compilation_error] if result[:compilation_error] + tracker.file_diagnostics[file_path] = result[:diagnostics] if result[:diagnostics]&.any? + end + + def print_summary(results, log, duration) + total = files.count + issues = results.problem_files.count + passed = results.successful.count + + log_summary(results, log, total, duration) + + parsed = total - results.failed.count - results.timeout.count + + puts "\n" + puts " #{bold("Summary:")}" - next unless compilation_errors[file] + puts " #{label("Version")} #{cyan(Herb.version)}" + puts " #{label("Checked")} #{cyan("#{total} #{pluralize(total, "file")}")}" + + if total > 1 + files_line = if issues.positive? + "#{bold(green("#{passed} clean"))} | #{bold(red("#{issues} with issues"))}" + else + bold(green("#{total} clean")) + end + + puts " #{label("Files")} #{files_line}" + end + + parser_parts = [] + parser_parts << stat(parsed, "parsed", :green) + parser_parts << stat(results.failed.count, "crashed", :red) if results.failed.any? + parser_parts << stat(results.template_error.count, pluralize(results.template_error.count, "template error"), :red) if results.template_error.any? + parser_parts << stat(results.unexpected_error.count, "unexpected", :red) if results.unexpected_error.any? + parser_parts << stat(results.strict_parse_error.count, "strict", :yellow) if results.strict_parse_error.any? + parser_parts << stat(results.analyze_parse_error.count, "analyze", :yellow) if results.analyze_parse_error.any? + puts " #{label("Parser")} #{parser_parts.join(" | ")}" + + skipped = total - passed - results.validation_error.count - results.compilation_failed.count - + results.strict_compilation_failed.count - results.invalid_ruby.count + + engine_parts = [] + engine_parts << stat(passed, "compiled", :green) + engine_parts << stat(results.validation_error.count, "validation", :yellow) if results.validation_error.any? + engine_parts << stat(results.compilation_failed.count, "compilation", :red) if results.compilation_failed.any? + engine_parts << stat(results.strict_compilation_failed.count, "strict", :yellow) if results.strict_compilation_failed.any? + engine_parts << stat(results.invalid_ruby.count, "produced invalid Ruby", :red) if results.invalid_ruby.any? + engine_parts << dimmed("#{skipped} skipped") if skipped.positive? + puts " #{label("Engine")} #{engine_parts.join(" | ")}" + + if results.timeout.any? + puts " #{label("Timeout")} #{stat(results.timeout.count, "timed out", :yellow)}" + end + + if duration + puts " #{label("Duration")} #{cyan(format_duration(duration))}" + end + + return unless issues.zero? && total > 1 + + puts "" + puts " #{bold(green("✓"))} #{green("All files are clean!")}" + end + + def log_summary(results, log, total, duration) + log.puts heading("Summary") + log.puts "Herb Version: #{Herb.version}" + log.puts "Total files: #{total}" + log.puts "Parser options: strict: true, analyze: true" + log.puts "" + log.puts "✅ Successful (parsed & compiled): #{results.successful.count} (#{percentage(results.successful.count, total)}%)" + log.puts "" + log.puts "--- Parser ---" + log.puts "❌ Parser crashed: #{results.failed.count} (#{percentage(results.failed.count, total)}%)" + log.puts "⚠️ Template errors: #{results.template_error.count} (#{percentage(results.template_error.count, total)}%)" + log.puts "❌ Unexpected parse errors: #{results.unexpected_error.count} (#{percentage(results.unexpected_error.count, total)}%)" + log.puts "🔒 Strict mode parse errors (ok with strict: false): #{results.strict_parse_error.count} (#{percentage(results.strict_parse_error.count, total)}%)" + log.puts "🔍 Analyze parse errors (ok with analyze: false): #{results.analyze_parse_error.count} (#{percentage(results.analyze_parse_error.count, total)}%)" + log.puts "" + log.puts "--- Engine ---" + log.puts "⚠️ Validation errors (ok without validators): #{results.validation_error.count} (#{percentage(results.validation_error.count, total)}%)" + log.puts "❌ Compilation errors: #{results.compilation_failed.count} (#{percentage(results.compilation_failed.count, total)}%)" + log.puts "🔒 Strict mode compilation errors (ok with strict: false): #{results.strict_compilation_failed.count} (#{percentage(results.strict_compilation_failed.count, total)}%)" + log.puts "🚨 Invalid Ruby output: #{results.invalid_ruby.count} (#{percentage(results.invalid_ruby.count, total)}%)" + log.puts "" + log.puts "--- Other ---" + log.puts "⏱️ Timed out: #{results.timeout.count} (#{percentage(results.timeout.count, total)}%)" + + return unless duration + + log.puts "\n⏱️ Total time: #{format_duration(duration)}" + end - log.puts "\n#{heading("COMPILATION ERROR")}" - log.puts "```" - log.puts compilation_errors[file][:error] - log.puts "```" + def print_file_lists(results, log) + log_file_lists(results, log) - if compilation_errors[file][:backtrace].any? - log.puts "\n#{heading("BACKTRACE")}" - log.puts "```" - log.puts compilation_errors[file][:backtrace].join("\n") - log.puts "```" + return unless results.problem_files.any? + + printed_section = false + + ISSUE_TYPES.each do |type| + file_list = results.send(type[:key]) + next unless file_list.any? + + puts "\n #{separator}" if printed_section + printed_section = true + + puts "\n" + puts " #{bold("#{type[:label]}:")}" + puts " #{dimmed(type[:hint])}" if type[:hint] + + file_list.each do |file| + relative = relative_path(file) + diagnostics = results.file_diagnostics[file] + + puts "" + puts " #{cyan(relative)}:" + + if diagnostics&.any? + diagnostics.each do |diagnostic| + severity = send(type[:color], type[:symbol]) + location = diagnostic[:line] ? dimmed("at #{diagnostic[:line]}:#{diagnostic[:column]}") : nil + lines = diagnostic[:message].split("\n") + puts " #{severity} #{bold(diagnostic[:name])} #{location}#{" #{dimmed("-")} " if location}#{dimmed(lines.first)}" + lines.drop(1).each do |line| + puts " #{dimmed(line)}" + end end - log.puts + else + severity = send(type[:color], type[:symbol]) + puts " #{severity} #{type[:label]}" end - end - unless no_timing - end_time = Time.now - duration = end_time - start_time - timing_message = "\n⏱️ Total time: #{format_duration(duration)}" - log.puts timing_message - puts timing_message + puts "\n #{dimmed(type[:file_hint].call(relative))}" if type[:file_hint] end + end + end - puts "\nResults saved to #{output_file}" unless no_log_file + def log_file_lists(results, log) + ISSUE_TYPES.each do |type| + file_list = results.send(type[:key]) + next unless file_list.any? - problem_files.any? - ensure - log.close unless no_log_file + log.puts "\n#{heading("Files: #{type[:label]}")}" + file_list.each { |file| log.puts file } end end - private + def print_issue_summary(results) + counts = results.diagnostic_counts + return if counts.empty? - def progress_bar(current, total, width = (IO.console&.winsize&.[](1) || 80) - "[] 100% (#{total}/#{total})".length) - progress = current.to_f / total - completed_length = (progress * width).to_i - completed = "█" * completed_length + puts "\n" + puts " #{bold("Issue summary:")}" - partial_index = ((progress * width) % 1 * 8).to_i - partial_chars = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"] - partial = partial_index.zero? ? "" : partial_chars[partial_index] + counts.each do |name, data| + count_text = dimmed("(#{data[:count]} #{pluralize(data[:count], "error")} in #{data[:files].size} #{pluralize(data[:files].size, "file")})") + puts " #{white(name)} #{count_text}" + end + end - remaining = " " * (width - completed_length - (partial.empty? ? 0 : 1)) - percentage = (progress * 100).to_i + def reportable_files?(results) + ISSUE_TYPES.any? { |type| type[:reportable] && results.send(type[:key]).any? } + end - # Format as [███████▋ ] 42% (123/292) - "[#{completed}#{partial}#{remaining}] #{percentage}% (#{current}/#{total})" + def print_reportable_files(results) + reportable_types = ISSUE_TYPES.select { |type| type[:reportable] } + reportable_files = reportable_types.flat_map { |type| + results.send(type[:key]).map { |file| [file, type] } + } + return if reportable_files.empty? + + reportable_breakdown = reportable_types.filter_map { |type| + count = results.send(type[:key]).count + "#{count} #{type[:label].downcase}" if count.positive? + } + + puts "\n" + puts " #{bold("Reportable issues:")}" + puts " #{dimmed("The following files likely failed due to issues in Herb, not in your templates.")}" + puts " #{dimmed("Reporting them helps improve Herb and makes it better for everyone.")}" + puts " #{dimmed("See the detailed output above for more information on why each file failed.")}" + puts "" + puts " #{dimmed("#{reportable_files.count} #{pluralize(reportable_files.count, "issue")} could be reported: #{reportable_breakdown.join(", ")}")}" + puts "" + + reportable_files.each do |(file_path, _issue_type)| + puts " #{relative_path(file_path)}" + end + + puts "" + puts " #{dimmed("Run `herb report ` to generate a copy-able report for filing an issue.")}" + puts " #{dimmed("Run `herb playground ` to visually inspect the parse result, see diagnostics, or check if it's already fixed on main.")}" + + puts "" + puts " #{dimmed("https://github.com/marcoroth/herb/issues")}" + end + + def log_problem_file_details(results, log) + return unless results.problem_files.any? + + log.puts "\n#{heading("FILE CONTENTS AND DETAILS")}" + + results.problem_files.each do |file| + next unless results.file_contents[file] + + divider = "=" * [80, file.length].max + + log.puts + log.puts divider + log.puts file + log.puts divider + + log.puts "\n#{heading("CONTENT")}" + log.puts "```erb" + log.puts results.file_contents[file] + log.puts "```" + + log_error_outputs(results.error_outputs[file], log) + log_parse_errors(results.parse_errors[file], log) + log_compilation_errors(results.compilation_errors[file], log) + end + end + + def log_error_outputs(error_output, log) + return unless error_output + + if error_output[:exit_code] + log.puts "\n#{heading("EXIT CODE")}" + log.puts error_output[:exit_code] + end + + if error_output[:stderr].strip.length.positive? + log.puts "\n#{heading("ERROR OUTPUT")}" + log.puts "```" + log.puts error_output[:stderr] + log.puts "```" + end + + return unless error_output[:stdout].strip.length.positive? + + log.puts "\n#{heading("STANDARD OUTPUT")}" + log.puts "```" + log.puts error_output[:stdout] + log.puts "```" + log.puts + end + + def log_parse_errors(parse_error, log) + return unless parse_error + + if parse_error[:stdout].strip.length.positive? + log.puts "\n#{heading("STANDARD OUTPUT")}" + log.puts "```" + log.puts parse_error[:stdout] + log.puts "```" + end + + if parse_error[:stderr].strip.length.positive? + log.puts "\n#{heading("ERROR OUTPUT")}" + log.puts "```" + log.puts parse_error[:stderr] + log.puts "```" + end + + return unless parse_error[:ast] + + log.puts "\n#{heading("AST")}" + log.puts "```" + log.puts parse_error[:ast] + log.puts "```" + log.puts + end + + def log_compilation_errors(compilation_error, log) + return unless compilation_error + + log.puts "\n#{heading("COMPILATION ERROR")}" + log.puts "```" + log.puts compilation_error[:error] + log.puts "```" + + return unless compilation_error[:backtrace].any? + + log.puts "\n#{heading("BACKTRACE")}" + log.puts "```" + log.puts compilation_error[:backtrace].join("\n") + log.puts "```" + log.puts + end + + def label(text, width = 12) + dimmed(text.ljust(width)) + end + + def stat(count, text, color) + value = "#{count} #{text}" + + if count.positive? + bold(send(color, value)) + else + bold(green(value)) + end + end + + def relative_path(absolute_path) + Pathname.new(absolute_path).relative_path_from(Pathname.pwd).to_s + end + + def pluralize(count, singular, plural = nil) + count == 1 ? singular : (plural || "#{singular}s") end def percentage(part, total) @@ -485,6 +846,21 @@ def percentage(part, total) ((part.to_f / total) * 100).round(1) end + def ensure_parallel! + return if defined?(Parallel) + + require "bundler/inline" + + gemfile(true, quiet: true) do + source "https://rubygems.org" + gem "parallel" + end + end + + def separator + dimmed("─" * 60) + end + def heading(text) prefix = "--- #{text.upcase} " diff --git a/sig/herb/engine.rbs b/sig/herb/engine.rbs index 3ac59a7ed..267217f03 100644 --- a/sig/herb/engine.rbs +++ b/sig/herb/engine.rbs @@ -25,6 +25,9 @@ module Herb class CompilationError < StandardError end + class InvalidRubyError < CompilationError + end + def initialize: (untyped input, ?untyped properties) -> untyped def self.h: (untyped value) -> untyped