module Gitlab
  module Sherlock
    # Class for profiling code on a per line basis.
    #
    # The LineProfiler class can be used to profile code on per line basis
    # without littering your code with Ruby implementation specific profiling
    # methods.
    #
    # This profiler only includes samples taking longer than a given threshold
    # and those that occur in the actual application (e.g. files from Gems are
    # ignored).
    class LineProfiler
      # The minimum amount of time that has to be spent in a file for it to be
      # included in a list of samples.
      MINIMUM_DURATION = 10.0

      # Profiles the given block.
      #
      # Example:
      #
      #     profiler = LineProfiler.new
      #
      #     retval, samples = profiler.profile do
      #       "cats are amazing"
      #     end
      #
      #     retval  # => "cats are amazing"
      #     samples # => [#<Gitlab::Sherlock::FileSample ...>, ...]
      #
      # Returns an Array containing the block's return value and an Array of
      # FileSample objects.
      def profile(&block)
        if mri?
          profile_mri(&block)
        else
          raise NotImplementedError,
            'Line profiling is not supported on this platform'
        end
      end

      # Profiles the given block using rblineprof (MRI only).
      def profile_mri
        require 'rblineprof'

        retval  = nil
        samples = lineprof(/^#{Rails.root.to_s}/) { retval = yield }

        file_samples = aggregate_rblineprof(samples)

        [retval, file_samples]
      end

      # Returns an Array of file samples based on the output of rblineprof.
      #
      # lineprof_stats - A Hash containing rblineprof statistics on a per file
      #                  basis.
      #
      # Returns an Array of FileSample objects.
      def aggregate_rblineprof(lineprof_stats)
        samples = []

        lineprof_stats.each do |(file, stats)|
          source_lines = File.read(file).each_line.to_a
          line_samples = []

          total_duration = microsec_to_millisec(stats[0][0])
          total_events   = stats[0][2]

          next if total_duration <= MINIMUM_DURATION

          stats[1..-1].each_with_index do |data, index|
            next unless source_lines[index]

            duration = microsec_to_millisec(data[0])
            events   = data[2]

            line_samples << LineSample.new(duration, events)
          end

          samples << FileSample
            .new(file, line_samples, total_duration, total_events)
        end

        samples
      end

      private

      def microsec_to_millisec(microsec)
        microsec / 1000.0
      end

      def mri?
        RUBY_ENGINE == 'ruby'
      end
    end
  end
end
