rlqualls/sugarcane

View on GitHub
lib/sugarcane/abc_check.rb

Summary

Maintainability
A
2 hrs
Test Coverage
require 'ripper'
require 'set'

require 'sugarcane/file'
require 'sugarcane/ext/array'
require 'sugarcane/task_runner'

module SugarCane

  # Creates violations for methods that are too complicated using a simple
  # algorithm run against the parse tree of a file to count assignments,
  # branches, and conditionals. Borrows heavily from metric_abc.
  class AbcCheck < Struct.new(:opts)

    def self.key; :abc; end
    def self.name; "ABC check"; end
    def self.options
      {
        abc_glob: ['Glob to run ABC metrics over',
                      default: '{app,lib}/**/*.rb',
                      variable: 'GLOB',
                      clobber: :no_abc],
        abc_max:  ['Ignore methods under this complexity',
                      default: 15,
                      cast:    :to_i,
                      clobber: :no_abc],
        abc_exclude: ['Exclude method from analysis (eg. Foo::Bar#method)',
                         variable: 'METHOD',
                         type: Array,
                         default: [],
                         clobber: :no_abc],
        no_abc:   ['Disable ABC checking',
                      cast: ->(x) { !x }]
      }
    end

    def violations
      return [] if opts[:no_abc]

      order worker.map(file_names) {|file_name|
        find_violations(file_name)
      }.flatten
    end

    protected

    def find_violations(file_name)
      ast = Ripper::SexpBuilder.new(SugarCane::File.contents(file_name)).parse
      case ast
      when nil
        ast_type = InvalidAst.new(file_name)
      else
        ast_type = RubyAst.new(file_name, max_allowed_complexity,
                               ast, exclusions)
      end
      ast_type.violations
    end

    # Null object for when the file cannot be parsed.
    class InvalidAst < Struct.new(:file_name)
      def violations
        [{file: file_name, description: "File contained invalid syntax"}]
      end
    end

    # Wrapper object around sexps returned from ripper.
    class RubyAst < Struct.new(:file_name, :max_allowed_complexity,
                               :sexps, :exclusions)

      def initialize(*args)
        super
        self.anon_method_add = true
      end

      def violations
        process_ast(sexps).
          select do
            |nesting, complexity| complexity[:value] > max_allowed_complexity
          end.
          map do |violation|
          {
            # Here, a violation is an array, like:
            # ["Class#method", {:value => xx, :line => xx}]
            file:        file_name,
            line:        violation.last[:line],
            label:       violation.first,
            value:       violation.last[:value],
            description: "Methods exceeded maximum allowed ABC complexity",
            menu_description: "#{violation.first} exceeded maximum "\
                              "ABC complexity"
          }
          end
      end

      protected

      # Stateful flag used to determine whether we are currently parsing an
      # anonymous class. See #container_label.
      attr_accessor :anon_method_add

      # Recursive function to process an AST. The `complexity` variable mutates,
      # which is a bit confusing. `nesting` does not.
      def process_ast(node, complexity = {}, nesting = [])
        if method_nodes.include?(node[0])
          nesting = nesting + [label_for(node)]
          desc = method_description(node, *nesting)
          unless excluded?(desc)
            complexity[desc] = {
              :value => calculate_abc(node),
              :line => node.line_number
            }
          end
        elsif parent = container_label(node)
          nesting = nesting + [parent]
        end

        if node.is_a? Array
          node[1..-1].each {|n| process_ast(n, complexity, nesting) if n }
        end
        complexity
      end

      def calculate_abc(method_node)
        a = count_nodes(method_node, assignment_nodes)
        b = count_nodes(method_node, branch_nodes) + 1
        c = count_nodes(method_node, condition_nodes)
        abc = Math.sqrt(a**2 + b**2 + c**2).round
        abc
      end

      def container_label(node)
        if container_nodes.include?(node[0])
          # def foo, def self.foo
          node[1][-1][1]
        elsif node[0] == :method_add_block
          if anon_method_add
            # Class.new do ...
            "(anon)"
          else
            # MyClass = Class.new do ...
            # parent already added when processing a parent node
            anon_method_add = true
            nil
          end
        elsif node[0] == :assign && node[2][0] == :method_add_block
          # MyClass = Class.new do ...
          self.anon_method_add = false
          node[1][-1][1]
        end
      end

      def label_for(node)
        # A default case is deliberately omitted since I know of no way this
        # could fail and want it to fail fast.
        node.detect {|x|
          [:@ident, :@op, :@kw, :@const, :@backtick].include?(x[0])
        }[1]
      end

      def count_nodes(node, types)
        node.flatten.select {|n| types.include?(n) }.length
      end

      def assignment_nodes
        [:assign, :opassign]
      end

      def method_nodes
        [:def, :defs]
      end

      def container_nodes
        [:class, :module]
      end

      def branch_nodes
        [:call, :fcall, :brace_block, :do_block]
      end

      def condition_nodes
        [:==, :===, :"<>", :"<=", :">=", :"=~", :>, :<, :else, :"<=>"]
      end

      METH_CHARS = { def: '#', defs: '.' }

      def excluded?(method_description)
        exclusions.include?(method_description)
      end

      def method_description(node, *modules, meth_name)
        separator = METH_CHARS.fetch(node.first)
        description = [modules.join('::'), meth_name].join(separator)
      end
    end

    def file_names
      Dir[opts.fetch(:abc_glob)]
    end

    def order(result)
      result.sort_by {|x| x[:value].to_i }.reverse
    end

    def max_allowed_complexity
      opts.fetch(:abc_max)
    end

    def exclusions
      opts.fetch(:abc_exclude, []).flatten.to_set
    end

    def worker
      SugarCane.task_runner(opts)
    end
  end
end