class Debride

A static code analyzer that points out possible dead methods.

Constants

PROJECT
RAILS_DSL_METHODS

Rails’ macro-style methods that setup method calls to happen during a rails app’s execution.

RAILS_MACRO_METHODS

Rails’ macro-style methods that define methods dynamically.

RAILS_VALIDATION_METHODS

Rails’ macro-style methods that count as method calls if their options include :if or :unless.

Attributes

called[RW]

A set of called method names.

known[RW]

A collection of know methods, mapping method name to implementing classes.

option[RW]

Command-line options.

Public Class Methods

file_extensions() click to toggle source
# File lib/debride.rb, line 42
def self.file_extensions
  %w[rb rake jbuilder] + load_plugins
end
load_plugins(proj = PROJECT) click to toggle source
# File lib/debride.rb, line 18
def self.load_plugins proj = PROJECT
  unless defined? @@plugins then
    @@plugins = []

    task_re = /#{PROJECT}_task/o
    plugins = Gem.find_files("#{PROJECT}_*.rb").reject { |p| p =~ task_re }

    plugins.each do |plugin|
      plugin_name = File.basename(plugin, ".rb").sub(/^#{PROJECT}_/o, "")
      next if @@plugins.include? plugin_name
      begin
        load plugin
        @@plugins << plugin_name
      rescue RuntimeError, LoadError => e
        warn "error loading #{plugin.inspect}: #{e.message}. skipping..."
      end
    end
  end

  @@plugins
rescue
  []
end
new(options = {}) click to toggle source

Create a new Debride instance w/ options

Calls superclass method
# File lib/debride.rb, line 207
def initialize options = {}
  self.option = { :whitelist => [] }.merge options
  self.known  = Hash.new { |h,k| h[k] = Set.new }
  self.called = Set.new
  super()
end
parse_options(args) click to toggle source

Parse command line options and return a hash of parsed option values.

# File lib/debride.rb, line 111
def self.parse_options args
  options = {
    :whitelist => %i[
                     extended
                     included
                     inherited
                     method_added
                     method_missing
                     prepended
                    ],
    :exclude => [],
    :format => :text,
  }

  op = OptionParser.new do |opts|
    opts.banner  = "debride [options] files_or_dirs"
    opts.version = Debride::VERSION

    opts.separator ""
    opts.separator "Specific options:"
    opts.separator ""

    opts.on("-h", "--help", "Display this help.") do
      puts opts
      exit
    end

    opts.on("-e", "--exclude FILE1,FILE2,ETC", Array, "Exclude files or directories in comma-separated list.") do |list|
      options[:exclude].concat list
    end

    opts.on("-w", "--whitelist FILE", String, "Whitelist these messages.") do |s|
      options[:whitelist] = File.read(s).split(/\n+/) rescue []
    end

    opts.on("-f", "--focus PATH", String, "Only report against this path") do |s|
      unless File.exist? s then
        abort "ERROR: --focus path #{s} doesn't exist."
      end

      s = "#{s.chomp "/"}/*" if File.directory?(s)

      options[:focus] = s
    end

    opts.on("-r", "--rails", "Add some rails call conversions.") do
      options[:rails] = true
    end

    opts.on("-m", "--minimum N", Integer, "Don't show hits less than N locs.") do |n|
      options[:minimum] = n
    end

    opts.on("-v", "--verbose", "Verbose. Show progress processing files.") do
      options[:verbose] = true
    end

    opts.on "--json" do
      options[:format] = :json
    end

    opts.on "--yaml" do
      options[:format] = :yaml
    end
  end

  op.parse! args

  abort op.to_s if args.empty?

  options
rescue OptionParser::InvalidOption => e
  warn op.to_s
  warn ""
  warn e.message
  exit 1
end
run(args) click to toggle source

Top level runner for bin/debride.

# File lib/debride.rb, line 49
def self.run args
  opt = parse_options args

  debride = Debride.new opt

  extensions = self.file_extensions
  glob = "**/*.{#{extensions.join(",")}}"
  expander = PathExpander.new(args, glob)
  files = expander.process
  excl  = debride.option[:exclude]
  excl.map! { |fd| File.directory?(fd) ? "#{fd}/**/*" : fd } if excl

  files = expander.filter_files files, StringIO.new(excl.join "\n") if excl

  debride.run(files)
  debride
end

Public Instance Methods

inspect() click to toggle source
# File lib/debride.rb, line 596
def inspect
  "Debride[current=%s]" % [signature]
end
missing() click to toggle source

Calculate the difference between known methods and called methods.

# File lib/debride.rb, line 469
def missing
  whitelist_regexps = []

  option[:whitelist].each do |s|
    if s =~ /^\/.+?\/$/ then
      whitelist_regexps << Regexp.new(s[1..-2])
    else
      called << s.to_sym
    end
  end

  not_called = known.keys - called.to_a

  whitelist_regexp = Regexp.union whitelist_regexps

  not_called.reject! { |s| whitelist_regexp =~ s.to_s }

  by_class = Hash.new { |h,k| h[k] = [] }

  not_called.each do |meth|
    known[meth].each do |klass|
      by_class[klass] << meth
    end
  end

  by_class.each do |klass, meths|
    by_class[klass] = meths.sort_by(&:to_s)
  end

  by_class.sort_by { |k,v| k }
end
missing_locations() click to toggle source
# File lib/debride.rb, line 501
def missing_locations
  focus = option[:focus]

  missing.map { |klass, meths|
    bad = meths.map { |meth|
      location =
        method_locations["#{klass}##{meth}"] ||
        method_locations["#{klass}::#{meth}"]

      if focus then
        path = location[/(.+):\d+/, 1]

        next unless File.fnmatch(focus, path)
      end

      [meth, location]
    }.compact

    [klass, bad]
  }
    .to_h
    .reject { |k,v| v.empty? }
end
name_to_string(exp) click to toggle source
# File lib/debride.rb, line 408
def name_to_string exp
  case exp.sexp_type
  when :const, :lit, :str then
    exp.last.to_s
  when :colon2 then
    _, lhs, rhs = exp
    "#{name_to_string lhs}::#{rhs}"
  when :colon3 then
    _, rhs = exp
    "::#{rhs}"
  when :self then # wtf?
    "self"
  else
    raise "Not handled: #{exp.inspect}"
  end
end
process_alias(exp) click to toggle source
# File lib/debride.rb, line 364
def process_alias exp
  _, (_, lhs), (_, rhs) = exp

  record_method lhs, exp.file, exp.line

  called << rhs

  exp
end
process_attrasgn(sexp) click to toggle source
# File lib/debride.rb, line 222
def process_attrasgn(sexp)
  _, _, method_name, * = sexp
  method_name = method_name.last if Sexp === method_name
  called << method_name
  process_until_empty sexp
  sexp
end
process_op_asgn2(sexp) click to toggle source

handle &&=, ||=, etc

# File lib/debride.rb, line 231
def process_op_asgn2(sexp)
  _, _, method_name, * = sexp
  called << method_name
  process_until_empty sexp
  sexp
end
process_rb(path_or_io) click to toggle source
# File lib/debride.rb, line 88
def process_rb path_or_io
  warn "Processing ruby: #{path_or_io}" if option[:verbose]

  case path_or_io
  when String then
    path, file = path_or_io, File.binread(path_or_io)
  when IO, StringIO then
    path, file = "(io)", path_or_io.read
  else
    raise "Unhandled type: #{path_or_io.class}:#{path_or_io.inspect}"
  end

  RubyParser.new.process(file, path, option[:timeout])
rescue Racc::ParseError, RegexpError => e
  warn "Parse Error parsing #{path}. Skipping."
  warn "  #{e.message}"
rescue Timeout::Error
  warn "TIMEOUT parsing #{path}. Skipping."
end
record_method(name, file, line) click to toggle source
# File lib/debride.rb, line 238
def record_method name, file, line
  signature = "#{klass_name}##{name}"
  method_locations[signature] = "#{file}:#{line}"
  known[name] << klass_name
end
report(io = $stdout) click to toggle source

Print out a report of suspects.

# File lib/debride.rb, line 528
def report io = $stdout
  focus = option[:focus]
  type  = option[:format] || :text

  send "report_#{type}", io, focus, missing_locations
end
report_json(io, focus, missing) click to toggle source
# File lib/debride.rb, line 572
def report_json io, focus, missing
  require "json"

  data = {
    :missing => missing
  }

  data[:focus] = focus if focus

  JSON.dump data, io
end
report_text(io, focus, missing) click to toggle source
# File lib/debride.rb, line 535
def report_text io, focus, missing
  if focus then
    io.puts "Focusing on #{focus}"
    io.puts
  end

  io.puts "These methods MIGHT not be called:"

  total = 0

  missing.each do |klass, meths|
    bad = meths.map { |(meth, location)|
      loc = if location then
              l0, l1 = location.split(/:/).last.scan(/\d+/).flatten.map(&:to_i)
              l1 ||= l0
              l1 - l0 + 1
            else
              1
            end

      next if option[:minimum] && loc < option[:minimum]

      total += loc

      "  %-35s %s (%d)" % [meth, location, loc]
    }.compact

    next if bad.empty?

    io.puts
    io.puts klass
    io.puts bad.join "\n"
  end
  io.puts
  io.puts "Total suspect LOC: %d" % [total]
end
report_yaml(io, focus, missing) click to toggle source
# File lib/debride.rb, line 584
def report_yaml io, focus, missing
  require "yaml"

  data = {
    :missing => missing
  }

  data[:focus] = focus if focus

  YAML.dump data, io
end
run(*files) click to toggle source
# File lib/debride.rb, line 67
def run(*files)
  files.flatten.each do |file|
    warn "Processing #{file}" if option[:verbose]

    ext = File.extname(file).sub(/^\./, "")
    ext = "rb" if ext.nil? || ext.empty?
    msg = "process_#{ext}"

    unless respond_to? msg then
      warn "  Unknown file type: #{ext}, defaulting to ruby" if option[:verbose]
      msg = "process_rb"
    end

    begin
      process send(msg, file)
    rescue RuntimeError, SyntaxError => e
      warn "  skipping #{file}: #{e.message}"
    end
  end
end