lib/utils/grepper.rb



require 'term/ansicolor'

class Utils::Grepper
  include Tins::Find
  include Utils::Patterns
  include Term::ANSIColor

  class Queue
    # The initialize method sets up a new instance with the specified maximum
    # size and empty data array.
    #
    # @param max_size [ Integer ] the maximum size limit for the data storage
    def initialize(max_size)
      @max_size, @data = max_size, []
    end

    # The max_size reader method provides access to the maximum size value.
    #
    # @return [ Integer ] the maximum size value stored in the instance
    attr_reader :max_size

    # The data method returns a duplicate of the internal data array.
    #
    # This method provides access to the internal @data instance variable by
    # returning a shallow copy of the array, ensuring that external
    # modifications do not affect the original data structure.
    #
    # @return [ Array ] a duplicate of the internal data array
    def data
      @data.dup
    end

    # The push method adds an element to the queue and removes the oldest
    # element if the maximum size is exceeded.
    #
    # @param x [ Object ] the element to be added to the queue
    #
    # @return [ Queue ] returns self to allow for method chaining
    def push(x)
      @data.shift if @data.size > @max_size
      @data << x
      self
    end
    alias << push
  end

  # The initialize method sets up the grepper instance with the provided
  # options.
  #
  # This method configures the grepper by processing the input options, setting up
  # the root directories for searching, initializing the configuration, and
  # preparing pattern matchers for filename and skip patterns. It also handles
  # queue initialization for buffering output when specified.
  #
  # @param opts [ Hash ] the options hash containing configuration settings
  # @option opts [ Hash ] :args the command-line arguments
  # @option opts [ Array ] :roots the root directories to search
  # @option opts [ Utils::ConfigFile ] :config the configuration file object
  # @option opts [ Hash ] :pattern the pattern-related options
  #
  # @return [ Utils::Grepper ] a new grepper instance configured with the
  # provided options
  def initialize(opts = {})
    @args  = opts[:args] || {}
    @roots = discover_roots(opts[:roots])
    @config = opts[:config] || Utils::ConfigFile.new
    if n = @args.values_at(*%w[A B C]).compact.first
      if n.to_s =~ /\A\d+\Z/ and (n = n.to_i) >= 1
        @queue = Queue.new n
      else
        raise ArgumentError, "needs to be an integer number >= 1"
      end
    end
    @paths  = []
    pattern_opts = opts.subhash(:pattern) | {
      :cset  => @args[?a],
      :icase => @args[?i] != ?n,
    }
    @pattern = choose(@args[?p], pattern_opts, default: ?r)
    @name_pattern =
      if name_pattern = @args[?N]
        RegexpPattern.new(:pattern => name_pattern)
      elsif name_pattern = @args[?n]
        FuzzyPattern.new(:pattern => name_pattern)
      end
    @skip_pattern =
      if skip_pattern = @args[?S]
        RegexpPattern.new(:pattern => skip_pattern)
      elsif skip_pattern = @args[?s]
        FuzzyPattern.new(:pattern => skip_pattern)
      end
  end

  # The paths reader method provides access to the paths instance variable.
  #
  # @return [ Array ] the array of paths stored in the instance variable
  attr_reader :paths

  # The pattern reader method provides access to the pattern matcher object.
  #
  # This method returns the internal pattern matcher that was initialized
  # during object creation, allowing external code to interact with the pattern
  # matching functionality directly.
  #
  # @return [ Utils::Patterns::Pattern ] the pattern matcher object used for
  # matching operations
  attr_reader :pattern

  # The match method processes a file to find matching content based on
  # configured patterns.
  # It handles directory pruning, file skipping, and various output formats
  # depending on the configuration.
  # The method opens files for reading, applies pattern matching, and manages
  # output through different code paths.
  # It supports features like line-based searching, git blame integration, and
  # multiple output modes.
  # The method returns the instance itself to allow for method chaining.
  #
  # @return [ Utils::Grepper ] returns self to allow for method chaining
  def match(filename)
    @filename = filename
    @output = []
    bn, s = File.basename(filename), File.stat(filename)
    if !s || s.directory? && @config.search.prune?(bn)
      @args[?v] and warn "Pruning #{filename.inspect}."
      prune
    end
    if s.file? && !@config.search.skip?(bn) &&
      (!@name_pattern || @name_pattern.match(bn))
    then
      File.open(filename, 'rb', encoding: Encoding::UTF_8) do |file|
        @args[?v] and warn "Matching #{filename.inspect}."
        if @args[?f]
          @output << filename
        else
          match_lines file
        end
      end
    else
      @args[?v] and warn "Skipping #{filename.inspect}."
    end
    unless @output.empty?
      case
      when @args[?g]
        @output.uniq!
        @output.each do |l|
          blamer = LineBlamer.for_line(l)
          if blame = blamer.perform
            author = nil
            blame.sub!(/^[0-9a-f^]+/) { Term::ANSIColor.yellow($&) }
            blame.sub!(/\(([^)]+)\)/) { author = $1; "(#{Term::ANSIColor.red($1)})" }
            if !@args[?G] || author&.downcase&.match?(@args[?G].downcase)
              puts "#{blame.chomp} #{Term::ANSIColor.blue(l)}"
            end
          end
        end
      when @args[?l], @args[?e], @args[?E], @args[?r]
        @output.uniq!
        @paths.concat @output
      else
        STDOUT.puts @output
      end
      @output.clear
    end
    self
  end

  # The match_lines method processes each line from a file using pattern
  # matching.
  #
  # This method iterates through lines in the provided file, applying pattern
  # matching to identify relevant content. It handles various output options
  # based on command-line arguments and manages queuing of lines for context
  # display.
  #
  # @param file [IO] the file object to be processed line by line
  def match_lines(file)
    for line in file
      if m = @pattern.match(line)
        @skip_pattern and @skip_pattern =~ line and next
        line[m.begin(0)...m.end(0)] = black on_white m[0]
        @queue and @queue << line
        case
        when @args[?l]
          @output << @filename
        when @args[?L], @args[?r], @args[?g]
          @output << "#{@filename}:#{file.lineno}"
        when @args[?e], @args[?E]
          @output << "#{@filename}:#{file.lineno}"
          break
        else
          @output << red("#{@filename}:#{file.lineno}")
          if @args[?B] or @args[?C]
            @output.concat @queue.data
          else
            @output << line
          end
          if @args[?A] or @args[?C]
            where = file.tell
            lineno = file.lineno
            @queue.max_size.times do
              file.eof? and break
              line = file.readline
              @queue << line
              @output << line
            end
            file.seek where
            file.lineno = lineno
          end
        end
      else
        @queue and @queue << line
      end
    end
  end

  # The search method performs a file search operation within specified roots,
  # filtering results based on various criteria including file extensions,
  # pruning directories, and skipping specific files.
  #
  # It utilizes a visit lambda to determine whether each file or directory
  # should be processed or skipped based on configuration settings and
  # command-line arguments. The method employs the find utility to traverse
  # the filesystem, executing match operations on qualifying files.
  #
  # @return [ Utils::Grepper ] returns self to allow for method chaining
  def search
    suffixes = Array(@args[?I])
    visit = -> filename {
      s  = filename.lstat
      bn = filename.pathname.basename
      if !s ||
          s.directory? && @config.search.prune?(bn) ||
          (s.file? || s.symlink?) && @config.search.skip?(bn) ||
          @args[?F] && s.symlink?
      then
        @args[?v] and warn "Pruning #{filename.inspect}."
        prune
      elsif suffixes.empty?
        true
      else
        suffixes.include?(filename.suffix)
      end
    }
    find(*@roots, visit: visit) do |filename|
      match(filename)
    end
    @paths = @paths.sort_by(&:source_location)
    self
  end

  private

  # The discover_roots method processes an array of root patterns and expands
  # them into actual directory paths.
  #
  # This method takes an array of root patterns, which may include glob
  # patterns, and uses Dir[r] to expand each pattern into matching directory
  # paths.
  # It handles the case where the input roots array is nil by defaulting to an
  # empty array. The expanded paths are then concatenated into a single result
  # array.
  #
  # @param roots [ Array<String>, nil ] an array of root patterns or nil
  #
  # @return [ Array<String> ] an array of expanded directory paths matching the input patterns
  def discover_roots(roots)
    roots ||= []
    roots.inject([]) { |rs, r| rs.concat Dir[r] }
  end
end