Skip to content

Instantly share code, notes, and snippets.

@thegedge
Last active May 19, 2020 08:17
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save thegedge/c05da047120c6b208dccce6025232610 to your computer and use it in GitHub Desktop.
Save thegedge/c05da047120c6b208dccce6025232610 to your computer and use it in GitHub Desktop.
Find unused Ruby methods (expect false positives) via static analysis
#!/usr/bin/env ruby
# frozen_string_literal: true
require "csv"
require "set"
begin
require "erubi"
rescue LoadError
end
class ComputeCallsAndDefinitions
include Enumerable
attr_reader :called, :defined
def initialize(paths)
@paths = paths
@called = Set.new
@defined = {}
compute_calls_and_definitions
end
private
def compute_calls_and_definitions
@paths.each do |path|
next if path =~ %r{\b(test|spec|vendor|sorbet)\b}
ast = parse(path)
next unless ast
# TODO also keep track of the callers. If it's only in test, probably dead
walk(ast) do |node, depth|
case node.type.downcase
#
# Method calls
#
# obj.foo(...)
when :call, :send, :csend
called << node.children[1].to_s
# obj&.foo(...)
when :qcall
called << node.children[1].to_s
# foo(...)
when :fcall
called << node.children[0].to_s
# foo
when :vcall
called << node.children[0].to_s
# A string/symbol literal could potentially be a method name
when :lit, :str, :sym
lit = node.children[0].to_s
begin
called << lit.to_s if lit.is_a?(Symbol) || lit.is_a?(String)
rescue EncodingError
# Ignore
end
#
# Method definitions
#
# def foo; ...; end
when :def, :defn
# Liquid drops have a lot of deefined functions used xternally
# TODO this is technically specified in YAML files, so we could get it from there
# TODO copied below, factor out handling here
next if path.end_with?("_drop.rb")
name = node.children[0].to_s
defined[name] ||= Set.new
defined[name] << path.to_s
# def foo.bar; ...; end
when :defs
next if path.end_with?("_drop.rb")
name = node.children[1].to_s
defined[name] ||= Set.new
defined[name] << path.to_s
# TODO :alias
# TODO :undef
end
end
end
end
def walk(node, depth = 0)
return unless node
return unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
yield node, depth
node.children.each do |node|
walk(node, depth + 1) do |node, depth|
yield node, depth
end
end
end
def parse(path)
if path =~ /\.erb$/
return nil unless defined?(ActionView::Template::Handlers::ERB::Erubi)
# We can't use ERB, because it doesn't produce proper code for
#
# <%= foo do |x| %>
# ...
# <% end %>
#
source, _encoding = ActionView::Template::Handlers::ERB::Erubi.new(File.read(path)).src
RubyVM::AbstractSyntaxTree.parse(source)
else
RubyVM::AbstractSyntaxTree.parse_file(path)
end
rescue Exception
STDERR.puts("Failed to parse #{path}")
nil
end
end
def exclude?(name, calls, paths)
# Assume that the same function name defined in two or more paths is called by something external
return true if paths.size > 1
# GraphQL response types have a lot of derive_/load_ things that are called in an external gem
return true if name.start_with?("derive_") || name.start_with?("load_")
# Liquid filters will be called by themes/templates/etc
return true if paths.any? { |path| path.end_with?("_filter.rb") }
# Event-y and callback-y methods
return true if name.end_with?("_callback") || name.start_with?("visit_") || name.start_with?("on_")
# Serialization calls these
return true if name.start_with?("include_")
# Currently ignoring all assign functions (TODO: properly detect when these are called)
return true if name.end_with?("=")
# Ignore things in controllers (TODO: parse routes file, or simply ignore public methods in controllers)
return true if paths.any? { |p| p.end_with?("_controller.rb") }
# REMOVEME only intended for discourse example
return true if name.start_with?("can_") && calls.include?("ensure_#{name}")
false
end
def paths(base)
Dir["#{base}/*.{rb,erb,rake}"]
end
def main(args)
args = ["."] if args.empty?
files = args.flat_map do |path|
base = File.join(path, "**")
paths(base)
end
data = ComputeCallsAndDefinitions.new(files)
# If input isn't piped in, but coming from a user, the stream will be associated with a TTY
defined = if STDIN.tty?
data.defined
else
STDIN.read.lines.to_h { |line| [line.chomp, ["stdin"]] }
end
calls = data.called
csv = CSV.new(STDOUT)
csv << ["Method Name", "Defined In", "Referenced outside tests?"]
defined.each do |name, paths|
name = name
next if exclude?(name, calls, paths)
is_referenced = calls.include?(name)
paths.each do |path|
csv << [name, path, is_referenced]
end
end
end
main(ARGV)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment