lib/herb/project.rb



# frozen_string_literal: true

require "io/console"
require "timeout"
require "tempfile"
require "pathname"
require "English"

module Herb
  class Project
    attr_accessor :project_path, :output_file

    def initialize(project_path, output_file: nil)
      @project_path = Pathname.new(
        project_path ? File.expand_path(".", project_path) : File.expand_path("../..", __dir__)
      )

      date = Time.now.strftime("%Y-%m-%d_%H-%M-%S")
      @output_file = output_file || "#{date}_erb_parsing_result_#{@project_path.basename}.log"
    end

    def glob
      "**/*.html.erb"
    end

    def full_path_glob
      project_path + glob
    end

    def absolute_path
      File.expand_path(@project_path, File.expand_path("../..", __dir__))
    end

    def files
      @files ||= Dir[full_path_glob]
    end

    def parse!
      File.open(output_file, "w") do |log|
        log.puts heading("METADATA")
        log.puts "Herb Version: #{Herb.version}"
        log.puts "Reported at: #{Time.now.strftime("%Y-%m-%dT%H:%M:%S")}\n\n"

        log.puts heading("PROJECT")
        log.puts "Path: #{absolute_path}"
        log.puts "Glob: #{"#{absolute_path}#{glob}"}\n\n"

        log.puts heading("PROCESSED FILES")

        if files.empty?
          message = "No .html.erb files found using #{full_path_glob}"
          log.puts message
          puts message
          next
        end

        print "\e[H\e[2J"

        successful_files = []
        failed_files = []
        timeout_files = []
        error_files = []
        error_outputs = {}
        file_contents = {}
        parse_errors = {}

        files.each_with_index do |file_path, index|
          total_failed = failed_files.count
          total_timeout = timeout_files.count
          total_errors = error_files.count

          lines_to_clear = 6 + total_failed + total_timeout + total_errors
          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.times { print "\e[1A\e[K" } if index.positive?

          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
          puts "Processing [#{index + 1}/#{files.count}]: #{relative_path}"

          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

          if error_files.any?
            puts
            puts "Files with parse errors:"
            error_files.each { |file| puts "  - #{file}" }
            puts
          end

          begin
            file_content = File.read(file_path)

            stdout_file = Tempfile.new("stdout")
            stderr_file = Tempfile.new("stderr")
            ast_file = Tempfile.new("ast")

            Timeout.timeout(1) do
              pid = Process.fork do
                $stdout.reopen(stdout_file.path, "w")
                $stderr.reopen(stderr_file.path, "w")

                begin
                  result = Herb.parse(file_content)

                  if result.failed?
                    File.open(ast_file.path, "w") do |f|
                      f.puts result.value.inspect
                    end

                    exit!(2)
                  end

                  exit!(0)
                rescue StandardError => e
                  warn "Ruby exception: #{e.class}: #{e.message}"
                  warn e.backtrace.join("\n") if e.backtrace
                  exit!(1)
                end
              end

              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"
                successful_files << file_path
              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

            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

            timeout_files << file_path
            file_contents[file_path] = file_content
          rescue StandardError => e
            message = "⚠️ Error processing #{file_path}: #{e.message}"
            log.puts message

            failed_files << file_path

            begin
              file_contents[file_path] = File.read(file_path)
            rescue StandardError => read_error
              log.puts "    Could not read file content: #{read_error.message}"
            end
          end
        end

        print "\e[1A\e[K"
        puts "Completed processing all files."

        print "\e[H\e[2J"

        log.puts ""

        summary = [
          heading("Summary"),
          "Total files: #{files.count}",
          "✅ Successful: #{successful_files.count}",
          "❌ Failed: #{failed_files.count}",
          "⚠️ Parse errors: #{error_files.count}",
          "⏱️ Timed out: #{timeout_files.count}"
        ]

        summary.each do |line|
          log.puts line
          puts line
        end

        if failed_files.any?
          log.puts "\n#{heading("Files that failed")}"
          puts "\nFiles that failed:"

          failed_files.each do |f|
            log.puts "- #{f}"
            puts "  - #{f}"
          end
        end

        if error_files.any?
          log.puts "\n#{heading("Files with parse errors")}"
          puts "\nFiles with parse errors:"

          error_files.each do |f|
            log.puts f
            puts "  - #{f}"
          end
        end

        if timeout_files.any?
          log.puts "\n#{heading("Files that timed out")}"
          puts "\nFiles that timed out:"

          timeout_files.each do |f|
            log.puts f
            puts "  - #{f}"
          end
        end

        problem_files = failed_files + timeout_files + error_files

        if problem_files.any?
          log.puts "\n#{heading("FILE CONTENTS AND DETAILS")}"

          problem_files.each do |file|
            next unless 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 file_contents[file]
            log.puts "```"

            if error_outputs[file]
              if error_outputs[file][:exit_code]
                log.puts "\n#{heading("EXIT CODE")}"
                log.puts error_outputs[file][:exit_code]
              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

              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

            next unless 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

            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

            next unless parse_errors[file][:ast]

            log.puts "\n#{heading("AST")}"
            log.puts "```"
            log.puts parse_errors[file][:ast]
            log.puts "```"
            log.puts
          end
        end

        puts "\nResults saved to #{output_file}"
      end
    end

    private

    def progress_bar(current, total, width = IO.console.winsize[1] - "[] 100% (#{total}/#{total})".length)
      progress = current.to_f / total
      completed_length = (progress * width).to_i
      completed = "█" * completed_length

      partial_index = ((progress * width) % 1 * 8).to_i
      partial_chars = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]
      partial = partial_index.zero? ? "" : partial_chars[partial_index]

      remaining = " " * (width - completed_length - (partial.empty? ? 0 : 1))
      percentage = (progress * 100).to_i

      # Format as [███████▋       ] 42% (123/292)
      "[#{completed}#{partial}#{remaining}] #{percentage}% (#{current}/#{total})"
    end

    def heading(text)
      prefix = "--- #{text.upcase} "

      prefix + ("-" * (80 - prefix.length))
    end
  end
end