lib/foodcritic/output.rb



require "set"

module FoodCritic
  class Output
    # Create Output writer.
    #
    # @param out [File] File-like object to output to.
    def initialize(out)
      @out = out
    end

    # Write a line of output.
    def puts(*args)
      @out.puts(*args)
    end
  end

  # Default output showing a summary view.
  class SummaryOutput < Output
    # Output a summary view only listing the matching rules, file and line
    # number.
    #
    # @param [Review] review The review to output.
    def output(review)
      puts review.to_s
    end
  end

  # Display rule matches with surrounding context.
  class ContextOutput < Output
    # Output the review showing matching lines with context.
    #
    # @param [Review] review The review to output.
    def output(review)
      unless review.respond_to?(:warnings)
        puts review; return
      end

      context = 3

      print_fn = lambda { |fn| ansi_print(fn, :red, nil, :bold) }
      print_rule = lambda { |warn| ansi_print(warn, :cyan, nil, :bold) }
      print_line = lambda { |line| ansi_print(line, nil, :red, :bold) }

      key_by_file_and_line(review).each do |fn, warnings|
        print_fn.call fn
        unless File.exist?(fn)
          print_rule.call warnings[1].to_a.join("\n")
          next
        end

        # Set of line numbers with warnings
        warn_lines = warnings.keys.to_set
        # Moving set of line numbers within the context of our position
        context_set = (0..context).to_set
        # The last line number we printed a warning for
        last_warn = -1

        File.open(fn) do |file|
          file.each do |line|
            context_set.add(file.lineno + context)
            context_set.delete(file.lineno - context - 1)

            # Find the first warning within our context
            context_warns = context_set & warn_lines
            next_warn = context_warns.min
            # We may need to interrupt the trailing context
            # of a previous warning
            next_warn = file.lineno if warn_lines.include? file.lineno

            # Display a warning
            if next_warn && next_warn > last_warn
              print_rule.call warnings[next_warn].to_a.join("\n")
              last_warn = next_warn
            end

            # Display any relevant lines
            if warn_lines.include? file.lineno
              print "%4i|" % file.lineno
              print_line.call line.chomp
            elsif not context_warns.empty?
              print "%4i|" % file.lineno
              puts line.chomp
            end
          end
        end
      end
    end

    private

    # Build a hash lookup by filename and line number for warnings found in the
    # specified review.
    #
    # @param [Review] review The review to convert.
    # @return [Hash] Nested hashes keyed by filename and line number.
    def key_by_file_and_line(review)
      warn_hash = {}
      review.warnings.each do |warning|
        filename = Pathname.new(warning.match[:filename]).cleanpath.to_s
        line_num = warning.match[:line].to_i
        warn_hash[filename] = {} unless warn_hash.key?(filename)
        unless warn_hash[filename].key?(line_num)
          warn_hash[filename][line_num] = Set.new
        end
        warn_hash[filename][line_num] << warning.rule
      end
      warn_hash
    end

    # Print an ANSI escape-code formatted string (and a newline)
    #
    # @param text [String] the string to format
    # @param fg [String] foreground color
    # @param bg [String] background color
    # @param attr [String] any formatting options
    def ansi_print(text, fg, bg = nil, attr = nil)
      unless @out.tty?
        puts text
        return
      end

      colors = %w{black red green yellow blue magenta cyan white}
      attrs = %w{reset bold dim underscore blink reverse hidden}
      escape = "\033[%sm"
      fmt = []
      fmt << 30 + colors.index(fg.to_s) if fg
      fmt << 40 + colors.index(bg.to_s) if bg
      fmt << attrs.index(attr.to_s) if attr
      if fmt
        puts "#{escape % fmt.join(";")}#{text}#{escape % 0}"
      else
        puts text
      end
    end
  end
end