seattlerb/flog

View on GitHub
lib/flog.rb

Summary

Maintainability
D
1 day
Test Coverage
require "sexp_processor"
require "ruby_parser"
require "timeout"

##
# Flog is a SexpProcessor that calculates a ABC (assignments,
# branches, conditionals) complexity metric with some ruby-aware
# enhancements and a compounding penalty for increasing depth.
#
# In essence, this calculates the most tortured code. The higher the
# score, the more pain the code is in and the harder it is to
# thoroughly test.

class Flog < MethodBasedSexpProcessor
  VERSION = "4.8.0" # :nodoc:

  ##
  # Cut off point where the report should stop unless --all given.

  DEFAULT_THRESHOLD = 0.60

  THRESHOLD = DEFAULT_THRESHOLD # :nodoc:

  ##
  # The scoring system hash. Maps node type to score.

  SCORES = Hash.new 1

  ##
  # Names of nodes that branch.

  BRANCHING = [ :and, :case, :else, :if, :or, :rescue, :until, :when, :while ]

  ##
  # Various non-call constructs

  OTHER_SCORES = {
    :alias          => 2,
    :assignment     => 1,
    :block          => 1,
    :block_pass     => 1,
    :block_call     => 1,
    :branch         => 1,
    :magic_number   => 0.25,
    :sclass         => 5,
    :super          => 1,
    :to_proc_icky!  => 10,
    :to_proc_lasgn  => 15,
    :yield          => 1,
  }

  ##
  # Eval forms

  SCORES.merge!(:define_method => 5,
                :eval          => 5,
                :module_eval   => 5,
                :class_eval    => 5,
                :instance_eval => 5)

  ##
  # Various "magic" usually used for "clever code"

  SCORES.merge!(:alias_method               => 2,
                :extend                     => 2,
                :include                    => 2,
                :instance_method            => 2,
                :instance_methods           => 2,
                :method_added               => 2,
                :method_defined?            => 2,
                :method_removed             => 2,
                :method_undefined           => 2,
                :private_class_method       => 2,
                :private_instance_methods   => 2,
                :private_method_defined?    => 2,
                :protected_instance_methods => 2,
                :protected_method_defined?  => 2,
                :public_class_method        => 2,
                :public_instance_methods    => 2,
                :public_method_defined?     => 2,
                :remove_method              => 2,
                :send                       => 3,
                :undef_method               => 2)

  ##
  # Calls that are ALMOST ALWAYS ABUSED!

  SCORES.merge!(:inject => 2)

  # :stopdoc:
  attr_accessor :multiplier
  attr_reader :calls, :option, :mass
  attr_reader :method_scores, :scores
  attr_reader :total_score, :totals
  attr_writer :threshold

  # :startdoc:

  ##
  # Add a score to the tally. Score can be predetermined or looked up
  # automatically. Uses multiplier for additional spankings.
  # Spankings!

  def add_to_score name, score = OTHER_SCORES[name]
    return if option[:methods] and method_stack.empty?
    @calls[signature][name] += score * @multiplier
  end

  ##
  # really?

  def average
    return 0 if calls.size == 0
    total_score / calls.size
  end

  ##
  # Calculates classes and methods scores.

  def calculate
    each_by_score threshold do |class_method, score, call_list|
      klass = class_method.scan(/.+(?=#|::)/).first

      method_scores[klass] << [class_method, score]
      scores[klass] += score
    end
  end

  ##
  # Returns true if the form looks like a "DSL" construct.
  #
  #   task :blah do ... end
  #   => s(:iter, s(:call, nil, :task, s(:lit, :blah)), ...)

  def dsl_name? args
    return false unless args and not args.empty?

    first_arg, = args
    first_arg = first_arg[1] if first_arg.sexp_type == :hash

    type, value, * = first_arg

    value if [:lit, :str].include? type
  end

  ##
  # Iterate over the calls sorted (descending) by score.

  def each_by_score max = nil
    current = 0

    calls.sort_by { |k,v| -totals[k] }.each do |class_method, call_list|
      score = totals[class_method]

      yield class_method, score, call_list

      current += score
      break if max and current >= max
    end
  end

  ##
  # Flog the given files. Deals with "-", and syntax errors.
  #
  # Not as smart as FlogCLI's #flog method as it doesn't traverse
  # dirs. Use PathExpander to expand dirs into files.

  def flog(*files)
    files.each do |file|
      next unless file == "-" or File.readable? file

      ruby = file == "-" ? $stdin.read : File.binread(file)

      flog_ruby ruby, file
    end

    calculate_total_scores
  end

  ##
  # Flog the given ruby source, optionally using file to provide paths
  # for methods. Smart. Handles syntax errors and timeouts so you
  # don't have to.

  def flog_ruby ruby, file="-", timeout = 10
    flog_ruby! ruby, file, timeout
  rescue Timeout::Error
    warn "TIMEOUT parsing #{file}. Skipping."
  rescue RubyParser::SyntaxError, Racc::ParseError => e
    q = option[:quiet]
    if e.inspect =~ /<\%|%\>/ or ruby =~ /<\%|%\>/ then
      return if q
      warn "#{e.inspect} at #{e.backtrace.first(5).join(", ")}"
      warn "\n...stupid lemmings and their bad erb templates... skipping"
    else
      warn "ERROR: parsing ruby file #{file}" unless q
      unless option[:continue] then
        warn "ERROR! Aborting. You may want to run with --continue."
        raise e
      end
      return if q
      warn "%s: %s at:\n  %s" % [e.class, e.message.strip,
                                 e.backtrace.first(5).join("\n  ")]
    end
  end

  ##
  # Flog the given ruby source, optionally using file to provide paths for
  # methods. Does not handle timeouts or syntax errors. See #flog_ruby.

  def flog_ruby! ruby, file="-", timeout = 10
    @parser = (option[:parser] || RubyParser).new

    warn "** flogging #{file}" if option[:verbose]

    ast = @parser.process ruby, file, timeout

    return unless ast

    mass[file] = ast.mass
    process ast
  end

  ##
  # Creates a new Flog instance with +options+.

  def initialize option = {}
    super()
    @option              = option
    @mass                = {}
    @parser              = nil
    @threshold           = option[:threshold] || DEFAULT_THRESHOLD
    self.auto_shift_type = true
    self.reset
  end

  ##
  # Returns the method/score pair of the maximum score.

  def max_method
    totals.max_by { |_, score| score }
  end

  ##
  # Returns the maximum score for a single method. Used for FlogTask.

  def max_score
    max_method.last
  end

  ##
  # For the duration of the block the complexity factor is increased
  # by #bonus This allows the complexity of sub-expressions to be
  # influenced by the expressions in which they are found.  Yields 42
  # to the supplied block.

  def penalize_by bonus
    @multiplier += bonus
    yield
    @multiplier -= bonus
  end

  ##
  # Reset score data

  def reset
    @totals           = @total_score = nil
    @multiplier       = 1.0
    @calls            = Hash.new { |h,k| h[k] = Hash.new 0 }
    @method_scores    = Hash.new { |h,k| h[k] = [] }
    @scores           = Hash.new 0
    method_locations.clear
  end

  ##
  # Compute the distance formula for a given tally

  def score_method(tally)
    a, b, c = 0, 0, 0
    tally.each do |cat, score|
      case cat
      when :assignment then a += score
      when :branch, :block_call then b += score
      else                  c += score
      end
    end
    Math.sqrt(a*a + b*b + c*c)
  end

  ##
  # Final threshold that is used for report

  def threshold
    option[:all] ? nil : total_score * @threshold
  end

  ##
  # Calculates the total score and populates @totals.

  def calculate_total_scores
    return if @totals

    @total_score = 0
    @totals = Hash.new(0)

    calls.each do |meth, tally|
      score = score_method(tally)

      @totals[meth] = score
      @total_score += score
    end
  end

  def no_method # :nodoc:
    @@no_method
  end

  ############################################################
  # Process Methods:

  # :stopdoc:
  def process_alias(exp)
    process exp.shift
    process exp.shift
    add_to_score :alias
    s()
  end

  def process_and(exp)
    add_to_score :branch
    penalize_by 0.1 do
      process exp.shift # lhs
      process exp.shift # rhs
    end
    s()
  end
  alias :process_or :process_and

  def process_attrasgn(exp)
    add_to_score :assignment
    process exp.shift # lhs
    exp.shift # name
    process_until_empty exp # rhs
    s()
  end

  def process_block(exp)
    penalize_by 0.1 do
      process_until_empty exp
    end
    s()
  end

  def process_block_pass(exp)
    arg = exp.shift

    add_to_score :block_pass

    return s() unless arg

    case arg.sexp_type
    when :lvar, :dvar, :ivar, :cvar, :self, :const, :colon2, :nil then # f(&b)
      # do nothing
    when :lit then                                                     # f(&:b)
      # do nothing -- this now costs about the same as a block
    when :call then                                                    # f(&x.b)
      # do nothing -- I don't like the indirection, but that's already scored
    when :lasgn then                                                   # f(&l=b)
      add_to_score :to_proc_lasgn
    when :iter, :dsym, :dstr, :hash, *BRANCHING then                   # below
      # f(&proc { ... })
      # f(&"#{...}")
      # f(&:"#{...}")
      # f(&if ... then ... end") and all other branching forms
      # f(&{ a: 42 })  WHY?!?
      add_to_score :to_proc_icky!
    else
      raise({:block_pass_even_ickier! => arg}.inspect)
    end

    process arg

    s()
  end

  def process_call(exp)
    penalize_by 0.2 do
      process exp.shift # recv
    end

    name = exp.shift

    penalize_by 0.2 do
      process_until_empty exp
    end

    add_to_score name, SCORES[name]

    s()
  end

  def process_safe_call(exp)
    penalize_by 0.3 do
      process exp.shift # recv
    end

    name = exp.shift

    penalize_by 0.2 do
      process_until_empty exp
    end

    add_to_score name, SCORES[name]

    s()
  end

  def process_case(exp)
    add_to_score :branch
    process exp.shift # recv
    penalize_by 0.1 do
      process_until_empty exp
    end
    s()
  end

  def process_class(exp)
    super do
      penalize_by 1.0 do
        process exp.shift # superclass expression
      end
      process_until_empty exp
    end
  end

  def process_dasgn_curr(exp) # FIX: remove
    add_to_score :assignment
    exp.shift # name
    process exp.shift # assigment, if any
    s()
  end
  alias :process_iasgn :process_dasgn_curr
  alias :process_lasgn :process_dasgn_curr

  # TODO:  it's not clear to me whether this can be generated at all.
  def process_else(exp)
    add_to_score :branch
    penalize_by 0.1 do
      process_until_empty exp
    end
    s()
  end
  alias :process_rescue :process_else
  alias :process_when   :process_else

  def process_if(exp)
    add_to_score :branch
    process exp.shift # cond
    penalize_by 0.1 do
      process exp.shift # true
      process exp.shift # false
    end
    s()
  end

  def process_iter(exp)
    context = (self.context - [:class, :module, :scope])
    context = context.uniq.sort_by { |s| s.to_s }

    exp.delete 0 # { || ... } has 0 in arg slot

    if context == [:block, :iter] or context == [:iter] then
      recv, = exp

      # DSL w/ names. eg task :name do ... end
      #   looks like s(:call, nil, :task, s(:lit, :name))
      #           or s(:call, nil, :task, s(:str, "name"))
      #           or s(:call, nil, :task, s(:hash, s(:lit, :name) ...))

      t, r, m, *a = recv

      if t == :call and r == nil and submsg = dsl_name?(a) then
        m = "#{m}(#{submsg})" if m and [String, Symbol].include?(submsg.class)
        in_klass m do                             # :task/namespace
          in_method submsg, exp.file, exp.line, exp.line_max do # :name
            process_until_empty exp
          end
        end
        return s()
      end
    end

    add_to_score :block_call

    process exp.shift # no penalty for LHS

    penalize_by 0.1 do
      process_until_empty exp
    end

    s()
  end

  Rational = Integer unless defined? Rational # 1.8 / 1.9

  def process_lit(exp)
    value = exp.shift
    case value
    when 0, -1 then
      # ignore those because they're used as array indicies instead of
      # first/last
    when Integer, Float, Rational, Complex then
      add_to_score :magic_number unless context[1] == :cdecl
    when Symbol, Regexp, Range then
      # do nothing
    else
      raise "Unhandled lit: #{value.inspect}:#{value.class}"
    end
    s()
  end

  def process_masgn(exp)
    add_to_score :assignment

    exp.map! { |s| Sexp === s ? s : s(:lasgn, s) }

    process_until_empty exp
    s()
  end

  def process_sclass(exp)
    super do
      penalize_by 0.5 do
        process exp.shift # recv
        process_until_empty exp
      end
    end

    add_to_score :sclass
    s()
  end

  def process_super(exp)
    add_to_score :super
    process_until_empty exp
    s()
  end

  def process_while(exp)
    add_to_score :branch
    penalize_by 0.1 do
      process exp.shift # cond
      process exp.shift # body
    end
    exp.shift # pre/post
    s()
  end
  alias :process_until :process_while

  def process_yield(exp)
    add_to_score :yield
    process_until_empty exp
    s()
  end
  # :startdoc:
end